From 901272079ca97f4bc8ad1f629f87ab1c38012828 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 31 Dec 2025 02:41:11 +0000 Subject: [PATCH 1/6] Add MCP (Model Context Protocol) server for Claude integration This enables Claude (or other LLMs) to control the UltraLog GUI in real-time: Architecture: - GUI runs an IPC server on localhost (port 52384) that receives commands - ultralog-mcp binary acts as MCP server, bridging Claude <-> GUI via TCP - User starts UltraLog GUI, then configures Claude Desktop to use ultralog-mcp MCP Tools available to Claude: - get_state: View loaded files, selected channels, cursor position - load_file/close_file: Manage ECU log files - list_channels: See available channels in a file - select_channel/deselect_channel: Control chart display - get_channel_data/get_channel_stats: Analyze channel data - create_computed_channel: Define virtual channels with formulas - evaluate_formula: Test formulas without creating permanent channels - set_time_range/set_cursor: Navigate the timeline - play/pause/stop: Control playback - find_peaks: Detect local maxima in channels - correlate_channels: Calculate Pearson correlation - show_scatter_plot/show_chart: Switch visualization modes New modules: - src/ipc/: IPC protocol and TCP server for GUI - src/mcp/: MCP server implementation using rmcp crate - src/bin/ultralog_mcp.rs: MCP server binary entry point --- Cargo.lock | 350 ++++++++++++++++ Cargo.toml | 9 + src/app.rs | 49 ++- src/bin/ultralog_mcp.rs | 53 +++ src/ipc/commands.rs | 310 ++++++++++++++ src/ipc/handler.rs | 890 ++++++++++++++++++++++++++++++++++++++++ src/ipc/mod.rs | 14 + src/ipc/server.rs | 167 ++++++++ src/lib.rs | 2 + src/mcp/client.rs | 117 ++++++ src/mcp/mod.rs | 10 + src/mcp/server.rs | 554 +++++++++++++++++++++++++ 12 files changed, 2524 insertions(+), 1 deletion(-) create mode 100644 src/bin/ultralog_mcp.rs create mode 100644 src/ipc/commands.rs create mode 100644 src/ipc/handler.rs create mode 100644 src/ipc/mod.rs create mode 100644 src/ipc/server.rs create mode 100644 src/mcp/client.rs create mode 100644 src/mcp/mod.rs create mode 100644 src/mcp/server.rs diff --git a/Cargo.lock b/Cargo.lock index d47193a..57e54f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,6 +95,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anyhow" version = "1.0.100" @@ -569,6 +578,20 @@ dependencies = [ "libc", ] +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "cipher" version = "0.4.4" @@ -781,6 +804,40 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "deflate64" version = "0.1.10" @@ -898,6 +955,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "ecolor" version = "0.33.3" @@ -1314,6 +1377,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -1321,6 +1399,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1329,6 +1408,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.31" @@ -1359,6 +1449,12 @@ dependencies = [ "syn", ] +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + [[package]] name = "futures-task" version = "0.3.31" @@ -1371,9 +1467,11 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", + "futures-sink", "futures-task", "memchr", "pin-project-lite", @@ -1594,6 +1692,30 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -1675,6 +1797,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1992,6 +2120,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "moxcms" version = "0.7.11" @@ -2492,6 +2631,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pastey" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" + [[package]] name = "pathdiff" version = "0.2.3" @@ -2871,6 +3016,26 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "regex" version = "1.12.2" @@ -2944,6 +3109,41 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528d42f8176e6e5e71ea69182b17d1d0a19a6b3b894b564678b74cd7cab13cfa" +dependencies = [ + "async-trait", + "base64", + "chrono", + "futures", + "pastey", + "pin-project-lite", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3f81daaa494eb8e985c9462f7d6ce1ab05e5299f48aafd76cdd3d8b060e6f59" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn", +] + [[package]] name = "ron" version = "0.11.0" @@ -3045,6 +3245,32 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schemars" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4908ad288c5035a8eb12cfdf0d49270def0a268ee162b75eeee0f85d155a7c45" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -3093,6 +3319,17 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_json" version = "1.0.148" @@ -3267,6 +3504,16 @@ dependencies = [ "serde", ] +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3279,6 +3526,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "strum" version = "0.27.2" @@ -3456,6 +3709,47 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-util" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.9.10+spec-1.1.0" @@ -3623,12 +3917,15 @@ dependencies = [ "rayon", "regex", "rfd", + "rmcp", + "schemars", "semver", "serde", "serde_json", "strum", "tar", "thiserror 2.0.17", + "tokio", "tracing", "tracing-subscriber", "ureq", @@ -4149,12 +4446,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.45.0" diff --git a/Cargo.toml b/Cargo.toml index dcb23c2..88c3d4c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,10 @@ path = "src/main.rs" name = "test_parser" path = "src/bin/test_parser.rs" +[[bin]] +name = "ultralog-mcp" +path = "src/bin/ultralog_mcp.rs" + [dependencies] # GUI Framework eframe = { version = "0.33", default-features = false, features = [ @@ -72,6 +76,11 @@ anyhow = "1.0" tracing = "0.1" tracing-subscriber = "0.3" +# MCP Server (Model Context Protocol) +rmcp = { version = "0.12", features = ["server", "transport-io", "macros"] } +tokio = { version = "1", features = ["full"] } +schemars = "1.0" + # UUID generation for anonymous user IDs uuid = { version = "1.0", features = ["v4"] } diff --git a/src/app.rs b/src/app.rs index cfdee2f..71f7c90 100644 --- a/src/app.rs +++ b/src/app.rs @@ -14,6 +14,7 @@ use std::thread; use crate::analysis::{AnalysisResult, AnalyzerRegistry}; use crate::analytics; use crate::computed::{ComputedChannel, ComputedChannelLibrary, FormulaEditorState}; +use crate::ipc::IpcServer; use crate::parsers::{ Aim, EcuMaster, EcuType, Emerald, Haltech, Link, Parseable, RomRaider, Speeduino, }; @@ -139,6 +140,9 @@ pub struct UltraLogApp { pub(crate) show_analysis_panel: bool, /// Selected category in analysis panel (None = show all) pub(crate) analysis_selected_category: Option, + // === MCP Integration === + /// IPC server for MCP integration (allows Claude to control the app) + ipc_server: Option, } impl Default for UltraLogApp { @@ -192,6 +196,7 @@ impl Default for UltraLogApp { analysis_results: HashMap::new(), show_analysis_panel: false, analysis_selected_category: None, + ipc_server: None, } } } @@ -231,7 +236,19 @@ impl UltraLogApp { // Apply fonts cc.egui_ctx.set_fonts(fonts); - Self::default() + // Start the IPC server for MCP integration + let mut app = Self::default(); + match IpcServer::start() { + Ok(server) => { + tracing::info!("MCP IPC server started on port {}", server.port()); + app.ipc_server = Some(server); + } + Err(e) => { + tracing::warn!("Failed to start MCP IPC server: {}", e); + } + } + + app } // ======================================================================== @@ -1351,6 +1368,33 @@ impl UltraLogApp { } }); } + + // ======================================================================== + // MCP Integration + // ======================================================================== + + /// Process pending IPC commands from the MCP server + fn process_ipc_commands(&mut self) { + // Collect commands first to avoid borrowing issues + let mut pending_commands = Vec::new(); + + if let Some(server) = &self.ipc_server { + // Collect up to 10 commands per frame to avoid blocking the UI + for _ in 0..10 { + if let Some(cmd) = server.poll_command() { + pending_commands.push(cmd); + } else { + break; + } + } + } + + // Now process the collected commands + for (command, response_sender) in pending_commands { + let response = self.handle_ipc_command(command); + let _ = response_sender.send(response); + } + } } // ============================================================================ @@ -1383,6 +1427,9 @@ impl eframe::App for UltraLogApp { // Handle keyboard shortcuts self.handle_keyboard_shortcuts(ctx); + // Handle IPC commands from MCP server + self.process_ipc_commands(); + // Apply dark theme ctx.set_visuals(egui::Visuals::dark()); diff --git a/src/bin/ultralog_mcp.rs b/src/bin/ultralog_mcp.rs new file mode 100644 index 0000000..6e8b25b --- /dev/null +++ b/src/bin/ultralog_mcp.rs @@ -0,0 +1,53 @@ +//! UltraLog MCP Server Binary +//! +//! This is the MCP (Model Context Protocol) server for UltraLog. It allows +//! LLMs like Claude to interact with the UltraLog application through the +//! standardized MCP protocol. +//! +//! # Usage +//! +//! 1. Start the UltraLog GUI application (it will start the IPC server) +//! 2. Configure Claude Desktop or Claude Code to use this MCP server +//! 3. Claude can now control UltraLog through the MCP tools +//! +//! # Configuration +//! +//! Add to your Claude Desktop config (`~/.config/claude-desktop/config.json`): +//! +//! ```json +//! { +//! "mcpServers": { +//! "ultralog": { +//! "command": "/path/to/ultralog-mcp", +//! "args": [] +//! } +//! } +//! } +//! ``` + +use ultralog::mcp::UltraLogMcpServer; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize simple logging to stderr (MCP uses stdio for protocol) + tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .with_max_level(tracing::Level::INFO) + .init(); + + tracing::info!("Starting UltraLog MCP Server v{}", env!("CARGO_PKG_VERSION")); + + // Check for port argument + let port = std::env::args() + .nth(1) + .and_then(|s| s.parse::().ok()) + .unwrap_or(ultralog::ipc::DEFAULT_IPC_PORT); + + let server = UltraLogMcpServer::with_port(port); + + tracing::info!("Connecting to UltraLog GUI on port {}", port); + + server.run_stdio().await?; + + Ok(()) +} diff --git a/src/ipc/commands.rs b/src/ipc/commands.rs new file mode 100644 index 0000000..ca7e2b0 --- /dev/null +++ b/src/ipc/commands.rs @@ -0,0 +1,310 @@ +//! IPC command and response types for GUI-MCP communication + +use serde::{Deserialize, Serialize}; + +/// Commands that can be sent from the MCP server to the GUI +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", content = "payload")] +pub enum IpcCommand { + /// Ping to check if the GUI is running + Ping, + + /// Get the current state of the application + GetState, + + /// Load a log file + LoadFile { path: String }, + + /// Close a loaded file + CloseFile { file_id: String }, + + /// List all channels in a loaded file + ListChannels { file_id: String }, + + /// Get data for a specific channel + GetChannelData { + file_id: String, + channel_name: String, + /// Optional time range (start, end) in seconds + time_range: Option<(f64, f64)>, + }, + + /// Get statistics for a channel + GetChannelStats { + file_id: String, + channel_name: String, + /// Optional time range for stats calculation + time_range: Option<(f64, f64)>, + }, + + /// Select a channel to display on the chart + SelectChannel { + file_id: String, + channel_name: String, + }, + + /// Deselect a channel from the chart + DeselectChannel { + file_id: String, + channel_name: String, + }, + + /// Deselect all channels + DeselectAllChannels, + + /// Create a computed channel + CreateComputedChannel { + name: String, + formula: String, + unit: String, + description: Option, + }, + + /// Delete a computed channel + DeleteComputedChannel { name: String }, + + /// List all computed channel templates + ListComputedChannels, + + /// Evaluate a formula without creating a permanent channel + EvaluateFormula { + file_id: String, + formula: String, + /// Optional time range + time_range: Option<(f64, f64)>, + }, + + /// Set the visible time range on the chart + SetTimeRange { start: f64, end: f64 }, + + /// Set the cursor position + SetCursor { time: f64 }, + + /// Start playback + Play { speed: Option }, + + /// Pause playback + Pause, + + /// Stop playback and reset cursor + Stop, + + /// Get values at the current cursor position + GetCursorValues { file_id: String }, + + /// Find peaks in a channel + FindPeaks { + file_id: String, + channel_name: String, + /// Minimum prominence for peak detection + min_prominence: Option, + }, + + /// Correlate two channels + CorrelateChannels { + file_id: String, + channel_a: String, + channel_b: String, + }, + + /// Switch to scatter plot view + ShowScatterPlot { + file_id: String, + x_channel: String, + y_channel: String, + }, + + /// Switch back to time series chart view + ShowChart, +} + +/// Responses from the GUI to the MCP server +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "status", content = "data")] +pub enum IpcResponse { + /// Successful response with optional data + Ok(Option), + + /// Error response + Error { message: String }, +} + +/// Data that can be returned in a successful response +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", content = "value")] +pub enum ResponseData { + /// Simple acknowledgment + Ack, + + /// Pong response + Pong, + + /// Application state + State(AppState), + + /// File was loaded successfully + FileLoaded(FileInfo), + + /// List of channels + Channels(Vec), + + /// Channel time series data + ChannelData { + times: Vec, + values: Vec, + }, + + /// Channel statistics + Stats(ChannelStats), + + /// Formula evaluation result + FormulaResult { + times: Vec, + values: Vec, + stats: ChannelStats, + }, + + /// Values at cursor position + CursorValues(Vec), + + /// List of computed channel templates + ComputedChannels(Vec), + + /// Peak detection results + Peaks(Vec), + + /// Correlation result + Correlation { + coefficient: f64, + interpretation: String, + }, +} + +/// Current application state +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppState { + /// List of loaded files + pub files: Vec, + /// Currently active file ID + pub active_file: Option, + /// Currently selected channels + pub selected_channels: Vec, + /// Current cursor time + pub cursor_time: Option, + /// Visible time range + pub visible_time_range: Option<(f64, f64)>, + /// Whether playback is active + pub is_playing: bool, + /// Current view mode + pub view_mode: String, +} + +/// Information about a loaded file +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileInfo { + /// Unique identifier for the file + pub id: String, + /// File path + pub path: String, + /// File name (for display) + pub name: String, + /// ECU type detected + pub ecu_type: String, + /// Number of channels + pub channel_count: usize, + /// Number of data records + pub record_count: usize, + /// Total duration in seconds + pub duration: f64, + /// Sample rate (records per second) + pub sample_rate: f64, +} + +/// Information about a channel +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChannelInfo { + /// Channel name + pub name: String, + /// Channel unit + pub unit: String, + /// Channel type/category + pub channel_type: String, + /// Whether this is a computed channel + pub is_computed: bool, + /// Min value in the data + pub min_value: Option, + /// Max value in the data + pub max_value: Option, +} + +/// Information about a selected channel on the chart +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SelectedChannelInfo { + /// File ID + pub file_id: String, + /// Channel name + pub channel_name: String, + /// Display color (hex) + pub color: String, +} + +/// Channel statistics +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChannelStats { + pub min: f64, + pub max: f64, + pub mean: f64, + pub std_dev: f64, + pub median: f64, + /// Number of samples + pub count: usize, + /// Time of minimum value + pub min_time: f64, + /// Time of maximum value + pub max_time: f64, +} + +/// Value at cursor position +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CursorValue { + pub channel_name: String, + pub value: f64, + pub unit: String, +} + +/// Information about a computed channel template +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ComputedChannelInfo { + pub id: String, + pub name: String, + pub formula: String, + pub unit: String, + pub description: String, +} + +/// A detected peak in the data +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Peak { + pub time: f64, + pub value: f64, + pub prominence: f64, +} + +impl IpcResponse { + /// Create a simple OK response + pub fn ok() -> Self { + Self::Ok(Some(ResponseData::Ack)) + } + + /// Create an OK response with data + pub fn ok_with_data(data: ResponseData) -> Self { + Self::Ok(Some(data)) + } + + /// Create an error response + pub fn error(message: impl Into) -> Self { + Self::Error { + message: message.into(), + } + } +} diff --git a/src/ipc/handler.rs b/src/ipc/handler.rs new file mode 100644 index 0000000..5b9a805 --- /dev/null +++ b/src/ipc/handler.rs @@ -0,0 +1,890 @@ +//! IPC command handler - processes commands from the MCP server +//! +//! This module contains the logic for handling IPC commands and generating responses. + +use std::path::PathBuf; + +use crate::app::UltraLogApp; +use crate::computed::{ComputedChannel, ComputedChannelTemplate}; +use crate::expression; +use crate::state::ActiveTool; + +use super::commands::*; + +impl UltraLogApp { + /// Handle an incoming IPC command and return a response + pub fn handle_ipc_command(&mut self, command: IpcCommand) -> IpcResponse { + match command { + IpcCommand::Ping => IpcResponse::ok_with_data(ResponseData::Pong), + + IpcCommand::GetState => self.handle_get_state(), + + IpcCommand::LoadFile { path } => self.handle_load_file(path), + + IpcCommand::CloseFile { file_id } => self.handle_close_file(&file_id), + + IpcCommand::ListChannels { file_id } => self.handle_list_channels(&file_id), + + IpcCommand::GetChannelData { + file_id, + channel_name, + time_range, + } => self.handle_get_channel_data(&file_id, &channel_name, time_range), + + IpcCommand::GetChannelStats { + file_id, + channel_name, + time_range, + } => self.handle_get_channel_stats(&file_id, &channel_name, time_range), + + IpcCommand::SelectChannel { + file_id, + channel_name, + } => self.handle_select_channel(&file_id, &channel_name), + + IpcCommand::DeselectChannel { + file_id, + channel_name, + } => self.handle_deselect_channel(&file_id, &channel_name), + + IpcCommand::DeselectAllChannels => self.handle_deselect_all_channels(), + + IpcCommand::CreateComputedChannel { + name, + formula, + unit, + description, + } => self.handle_create_computed_channel(name, formula, unit, description), + + IpcCommand::DeleteComputedChannel { name } => { + self.handle_delete_computed_channel(&name) + } + + IpcCommand::ListComputedChannels => self.handle_list_computed_channels(), + + IpcCommand::EvaluateFormula { + file_id, + formula, + time_range, + } => self.handle_evaluate_formula(&file_id, &formula, time_range), + + IpcCommand::SetTimeRange { start, end } => self.handle_set_time_range(start, end), + + IpcCommand::SetCursor { time } => self.handle_set_cursor(time), + + IpcCommand::Play { speed } => self.handle_play(speed), + + IpcCommand::Pause => self.handle_pause(), + + IpcCommand::Stop => self.handle_stop(), + + IpcCommand::GetCursorValues { file_id } => self.handle_get_cursor_values(&file_id), + + IpcCommand::FindPeaks { + file_id, + channel_name, + min_prominence, + } => self.handle_find_peaks(&file_id, &channel_name, min_prominence), + + IpcCommand::CorrelateChannels { + file_id, + channel_a, + channel_b, + } => self.handle_correlate_channels(&file_id, &channel_a, &channel_b), + + IpcCommand::ShowScatterPlot { + file_id, + x_channel, + y_channel, + } => self.handle_show_scatter_plot(&file_id, &x_channel, &y_channel), + + IpcCommand::ShowChart => self.handle_show_chart(), + } + } + + // ======================================================================== + // Command Handlers + // ======================================================================== + + fn handle_get_state(&self) -> IpcResponse { + let files: Vec = self + .files + .iter() + .enumerate() + .map(|(idx, f)| self.file_to_info(idx, f)) + .collect(); + + let active_file = self.active_tab.map(|t| self.tabs[t].file_index.to_string()); + + let selected_channels: Vec = self + .get_selected_channels() + .iter() + .map(|c| SelectedChannelInfo { + file_id: c.file_index.to_string(), + channel_name: c.channel.name(), + color: format!( + "#{:02x}{:02x}{:02x}", + self.get_channel_color(c.color_index)[0], + self.get_channel_color(c.color_index)[1], + self.get_channel_color(c.color_index)[2] + ), + }) + .collect(); + + let state = AppState { + files, + active_file, + selected_channels, + cursor_time: self.get_cursor_time(), + visible_time_range: self.get_time_range(), + is_playing: self.is_playing, + view_mode: match self.active_tool { + ActiveTool::LogViewer => "chart".to_string(), + ActiveTool::ScatterPlot => "scatter".to_string(), + ActiveTool::Histogram => "histogram".to_string(), + }, + }; + + IpcResponse::ok_with_data(ResponseData::State(state)) + } + + fn handle_load_file(&mut self, path: String) -> IpcResponse { + let path_buf = PathBuf::from(&path); + + if !path_buf.exists() { + return IpcResponse::error(format!("File not found: {}", path)); + } + + // Check if already loaded + if let Some(idx) = self.files.iter().position(|f| f.path == path_buf) { + let info = self.file_to_info(idx, &self.files[idx]); + return IpcResponse::ok_with_data(ResponseData::FileLoaded(info)); + } + + // Start loading - this is async, so we need to return immediately + // The file will be available on the next GetState call + self.start_loading_file(path_buf); + + IpcResponse::ok_with_data(ResponseData::Ack) + } + + fn handle_close_file(&mut self, file_id: &str) -> IpcResponse { + match file_id.parse::() { + Ok(idx) if idx < self.files.len() => { + self.remove_file(idx); + IpcResponse::ok() + } + _ => IpcResponse::error(format!("Invalid file ID: {}", file_id)), + } + } + + fn handle_list_channels(&self, file_id: &str) -> IpcResponse { + let file_idx = match file_id.parse::() { + Ok(idx) if idx < self.files.len() => idx, + _ => return IpcResponse::error(format!("Invalid file ID: {}", file_id)), + }; + + let file = &self.files[file_idx]; + let mut channels: Vec = file + .log + .channels + .iter() + .enumerate() + .map(|(idx, c)| { + let data = file.log.get_channel_data(idx); + let (min_val, max_val) = if data.is_empty() { + (None, None) + } else { + let min = data.iter().cloned().fold(f64::INFINITY, f64::min); + let max = data.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + (Some(min), Some(max)) + }; + + ChannelInfo { + name: c.name(), + unit: c.unit().to_string(), + channel_type: c.type_name(), + is_computed: false, + min_value: min_val, + max_value: max_val, + } + }) + .collect(); + + // Add computed channels + if let Some(computed) = self.file_computed_channels.get(&file_idx) { + for c in computed { + let (min_val, max_val) = if let Some(data) = &c.cached_data { + if data.is_empty() { + (None, None) + } else { + let min = data.iter().cloned().fold(f64::INFINITY, f64::min); + let max = data.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + (Some(min), Some(max)) + } + } else { + (None, None) + }; + + channels.push(ChannelInfo { + name: c.name().to_string(), + unit: c.unit().to_string(), + channel_type: "Computed".to_string(), + is_computed: true, + min_value: min_val, + max_value: max_val, + }); + } + } + + IpcResponse::ok_with_data(ResponseData::Channels(channels)) + } + + fn handle_get_channel_data( + &self, + file_id: &str, + channel_name: &str, + time_range: Option<(f64, f64)>, + ) -> IpcResponse { + let file_idx = match file_id.parse::() { + Ok(idx) if idx < self.files.len() => idx, + _ => return IpcResponse::error(format!("Invalid file ID: {}", file_id)), + }; + + let file = &self.files[file_idx]; + + // Find channel by name + let channel_idx = file + .log + .channels + .iter() + .position(|c| c.name().eq_ignore_ascii_case(channel_name)); + + let (times, values) = if let Some(idx) = channel_idx { + let all_times = file.log.get_times_as_f64().to_vec(); + let all_values = file.log.get_channel_data(idx); + self.filter_by_time_range(all_times, all_values, time_range) + } else { + // Check computed channels + if let Some(computed) = self.file_computed_channels.get(&file_idx) { + if let Some(c) = computed.iter().find(|c| c.name().eq_ignore_ascii_case(channel_name)) { + if let Some(data) = &c.cached_data { + let all_times = file.log.get_times_as_f64().to_vec(); + self.filter_by_time_range(all_times, data.clone(), time_range) + } else { + return IpcResponse::error("Computed channel not evaluated yet"); + } + } else { + return IpcResponse::error(format!("Channel not found: {}", channel_name)); + } + } else { + return IpcResponse::error(format!("Channel not found: {}", channel_name)); + } + }; + + IpcResponse::ok_with_data(ResponseData::ChannelData { times, values }) + } + + fn handle_get_channel_stats( + &self, + file_id: &str, + channel_name: &str, + time_range: Option<(f64, f64)>, + ) -> IpcResponse { + // First get the data + let data_response = self.handle_get_channel_data(file_id, channel_name, time_range); + + match data_response { + IpcResponse::Ok(Some(ResponseData::ChannelData { times, values })) => { + if values.is_empty() { + return IpcResponse::error("No data in range"); + } + + let stats = self.compute_stats(×, &values); + IpcResponse::ok_with_data(ResponseData::Stats(stats)) + } + IpcResponse::Error { message } => IpcResponse::error(message), + _ => IpcResponse::error("Unexpected response"), + } + } + + fn handle_select_channel(&mut self, file_id: &str, channel_name: &str) -> IpcResponse { + let file_idx = match file_id.parse::() { + Ok(idx) if idx < self.files.len() => idx, + _ => return IpcResponse::error(format!("Invalid file ID: {}", file_id)), + }; + + // Ensure we have a tab for this file + if self.tabs.iter().all(|t| t.file_index != file_idx) { + self.switch_to_file_tab(file_idx); + } else { + // Switch to the existing tab + if let Some(tab_idx) = self.tabs.iter().position(|t| t.file_index == file_idx) { + self.active_tab = Some(tab_idx); + self.selected_file = Some(file_idx); + } + } + + let file = &self.files[file_idx]; + + // Find channel by name + if let Some(idx) = file + .log + .channels + .iter() + .position(|c| c.name().eq_ignore_ascii_case(channel_name)) + { + self.add_channel(file_idx, idx); + IpcResponse::ok() + } else { + // Check computed channels + if let Some(computed) = self.file_computed_channels.get(&file_idx) { + if let Some(comp_idx) = computed.iter().position(|c| c.name().eq_ignore_ascii_case(channel_name)) { + let channel_idx = file.log.channels.len() + comp_idx; + self.add_channel(file_idx, channel_idx); + IpcResponse::ok() + } else { + IpcResponse::error(format!("Channel not found: {}", channel_name)) + } + } else { + IpcResponse::error(format!("Channel not found: {}", channel_name)) + } + } + } + + fn handle_deselect_channel(&mut self, file_id: &str, channel_name: &str) -> IpcResponse { + let file_idx = match file_id.parse::() { + Ok(idx) if idx < self.files.len() => idx, + _ => return IpcResponse::error(format!("Invalid file ID: {}", file_id)), + }; + + // Find the channel in selected channels + if let Some(tab_idx) = self.active_tab { + let tab = &self.tabs[tab_idx]; + if let Some(idx) = tab.selected_channels.iter().position(|c| { + c.file_index == file_idx && c.channel.name().eq_ignore_ascii_case(channel_name) + }) { + self.remove_channel(idx); + return IpcResponse::ok(); + } + } + + IpcResponse::error(format!("Channel not selected: {}", channel_name)) + } + + fn handle_deselect_all_channels(&mut self) -> IpcResponse { + if let Some(tab_idx) = self.active_tab { + self.tabs[tab_idx].selected_channels.clear(); + } + IpcResponse::ok() + } + + fn handle_create_computed_channel( + &mut self, + name: String, + formula: String, + unit: String, + description: Option, + ) -> IpcResponse { + // Validate the formula + let available_channels = self.get_available_channel_names(); + if let Err(e) = expression::validate_formula(&formula, &available_channels) { + return IpcResponse::error(format!("Invalid formula: {}", e)); + } + + // Create the template + let template = ComputedChannelTemplate::new( + name.clone(), + formula.clone(), + unit, + description.unwrap_or_default(), + ); + + // Add to library + self.computed_library.add_template(template.clone()); + let _ = self.computed_library.save(); + + // Create and add computed channel to active file + let mut computed = ComputedChannel::from_template(template); + + // Evaluate it for the active file + if let Some(tab_idx) = self.active_tab { + let file_idx = self.tabs[tab_idx].file_index; + if file_idx < self.files.len() { + let file = &self.files[file_idx]; + + // Build bindings + let refs = expression::extract_channel_references(&formula); + match expression::build_channel_bindings(&refs, &available_channels) { + Ok(bindings) => { + computed.channel_bindings = bindings.clone(); + + // Evaluate + match expression::evaluate_all_records( + &formula, + &bindings, + &file.log.data, + file.log.get_times_as_f64(), + ) { + Ok(values) => { + computed.cached_data = Some(values); + } + Err(e) => { + computed.error = Some(e); + } + } + } + Err(e) => { + computed.error = Some(e); + } + } + + self.add_computed_channel(computed); + } + } + + IpcResponse::ok() + } + + fn handle_delete_computed_channel(&mut self, name: &str) -> IpcResponse { + // Remove from library + if let Some(pos) = self + .computed_library + .templates + .iter() + .position(|t| t.name.eq_ignore_ascii_case(name)) + { + self.computed_library.templates.remove(pos); + let _ = self.computed_library.save(); + } + + // Remove from active file's computed channels + if let Some(tab_idx) = self.active_tab { + let file_idx = self.tabs[tab_idx].file_index; + if let Some(computed) = self.file_computed_channels.get_mut(&file_idx) { + if let Some(pos) = computed.iter().position(|c| c.name().eq_ignore_ascii_case(name)) { + computed.remove(pos); + } + } + } + + IpcResponse::ok() + } + + fn handle_list_computed_channels(&self) -> IpcResponse { + let channels: Vec = self + .computed_library + .templates + .iter() + .map(|t| ComputedChannelInfo { + id: t.id.clone(), + name: t.name.clone(), + formula: t.formula.clone(), + unit: t.unit.clone(), + description: t.description.clone(), + }) + .collect(); + + IpcResponse::ok_with_data(ResponseData::ComputedChannels(channels)) + } + + fn handle_evaluate_formula( + &self, + file_id: &str, + formula: &str, + time_range: Option<(f64, f64)>, + ) -> IpcResponse { + let file_idx = match file_id.parse::() { + Ok(idx) if idx < self.files.len() => idx, + _ => return IpcResponse::error(format!("Invalid file ID: {}", file_id)), + }; + + let file = &self.files[file_idx]; + let available_channels: Vec = + file.log.channels.iter().map(|c| c.name()).collect(); + + // Validate formula + if let Err(e) = expression::validate_formula(formula, &available_channels) { + return IpcResponse::error(format!("Invalid formula: {}", e)); + } + + // Build bindings and evaluate + let refs = expression::extract_channel_references(formula); + let bindings = match expression::build_channel_bindings(&refs, &available_channels) { + Ok(b) => b, + Err(e) => return IpcResponse::error(e), + }; + + let all_values = match expression::evaluate_all_records( + formula, + &bindings, + &file.log.data, + file.log.get_times_as_f64(), + ) { + Ok(v) => v, + Err(e) => return IpcResponse::error(e), + }; + + let all_times = file.log.get_times_as_f64().to_vec(); + let (times, values) = self.filter_by_time_range(all_times, all_values, time_range); + + let stats = self.compute_stats(×, &values); + + IpcResponse::ok_with_data(ResponseData::FormulaResult { + times, + values, + stats, + }) + } + + fn handle_set_time_range(&mut self, start: f64, end: f64) -> IpcResponse { + self.set_time_range(Some((start, end))); + self.set_chart_interacted(true); + IpcResponse::ok() + } + + fn handle_set_cursor(&mut self, time: f64) -> IpcResponse { + self.set_cursor_time(Some(time)); + let record = self.find_record_at_time(time); + self.set_cursor_record(record); + IpcResponse::ok() + } + + fn handle_play(&mut self, speed: Option) -> IpcResponse { + if let Some(s) = speed { + self.playback_speed = s.clamp(0.25, 8.0); + } + self.is_playing = true; + self.last_frame_time = Some(std::time::Instant::now()); + IpcResponse::ok() + } + + fn handle_pause(&mut self) -> IpcResponse { + self.is_playing = false; + IpcResponse::ok() + } + + fn handle_stop(&mut self) -> IpcResponse { + self.is_playing = false; + if let Some((min, _)) = self.get_time_range() { + self.set_cursor_time(Some(min)); + self.set_cursor_record(Some(0)); + } + IpcResponse::ok() + } + + fn handle_get_cursor_values(&self, file_id: &str) -> IpcResponse { + let file_idx = match file_id.parse::() { + Ok(idx) if idx < self.files.len() => idx, + _ => return IpcResponse::error(format!("Invalid file ID: {}", file_id)), + }; + + let cursor_record = match self.get_cursor_record() { + Some(r) => r, + None => return IpcResponse::error("No cursor position set"), + }; + + let file = &self.files[file_idx]; + let mut values = Vec::new(); + + for (idx, channel) in file.log.channels.iter().enumerate() { + if let Some(value) = self.get_value_at_record(file_idx, idx, cursor_record) { + values.push(CursorValue { + channel_name: channel.name(), + value, + unit: channel.unit().to_string(), + }); + } + } + + IpcResponse::ok_with_data(ResponseData::CursorValues(values)) + } + + fn handle_find_peaks( + &self, + file_id: &str, + channel_name: &str, + min_prominence: Option, + ) -> IpcResponse { + let data_response = self.handle_get_channel_data(file_id, channel_name, None); + + match data_response { + IpcResponse::Ok(Some(ResponseData::ChannelData { times, values })) => { + let peaks = self.find_peaks_in_data(×, &values, min_prominence.unwrap_or(0.1)); + IpcResponse::ok_with_data(ResponseData::Peaks(peaks)) + } + IpcResponse::Error { message } => IpcResponse::error(message), + _ => IpcResponse::error("Unexpected response"), + } + } + + fn handle_correlate_channels( + &self, + file_id: &str, + channel_a: &str, + channel_b: &str, + ) -> IpcResponse { + let data_a = self.handle_get_channel_data(file_id, channel_a, None); + let data_b = self.handle_get_channel_data(file_id, channel_b, None); + + match (data_a, data_b) { + ( + IpcResponse::Ok(Some(ResponseData::ChannelData { values: a, .. })), + IpcResponse::Ok(Some(ResponseData::ChannelData { values: b, .. })), + ) => { + if a.len() != b.len() || a.is_empty() { + return IpcResponse::error("Channels have different lengths or are empty"); + } + + let coefficient = self.compute_correlation(&a, &b); + let interpretation = self.interpret_correlation(coefficient); + + IpcResponse::ok_with_data(ResponseData::Correlation { + coefficient, + interpretation, + }) + } + (IpcResponse::Error { message }, _) | (_, IpcResponse::Error { message }) => { + IpcResponse::error(message) + } + _ => IpcResponse::error("Unexpected response"), + } + } + + fn handle_show_scatter_plot( + &mut self, + file_id: &str, + x_channel: &str, + y_channel: &str, + ) -> IpcResponse { + let file_idx = match file_id.parse::() { + Ok(idx) if idx < self.files.len() => idx, + _ => return IpcResponse::error(format!("Invalid file ID: {}", file_id)), + }; + + // Find channel indices first (while we only have immutable borrow) + let file = &self.files[file_idx]; + let x_idx = file + .log + .channels + .iter() + .position(|c| c.name().eq_ignore_ascii_case(x_channel)); + let y_idx = file + .log + .channels + .iter() + .position(|c| c.name().eq_ignore_ascii_case(y_channel)); + + // Switch to scatter plot view + self.active_tool = ActiveTool::ScatterPlot; + + // Configure the scatter plot (now we can get mutable borrow) + if let Some(state) = self.get_scatter_plot_state_mut() { + if let (Some(x), Some(y)) = (x_idx, y_idx) { + state.left.x_channel = Some(x); + state.left.y_channel = Some(y); + } + } + + IpcResponse::ok() + } + + fn handle_show_chart(&mut self) -> IpcResponse { + self.active_tool = ActiveTool::LogViewer; + IpcResponse::ok() + } + + // ======================================================================== + // Helper Functions + // ======================================================================== + + fn file_to_info(&self, idx: usize, file: &crate::state::LoadedFile) -> FileInfo { + let times = file.log.get_times_as_f64(); + let duration = if times.len() >= 2 { + times.last().unwrap_or(&0.0) - times.first().unwrap_or(&0.0) + } else { + 0.0 + }; + + let sample_rate = if duration > 0.0 && times.len() > 1 { + (times.len() - 1) as f64 / duration + } else { + 0.0 + }; + + FileInfo { + id: idx.to_string(), + path: file.path.to_string_lossy().to_string(), + name: file.name.clone(), + ecu_type: file.ecu_type.name().to_string(), + channel_count: file.log.channels.len(), + record_count: file.log.data.len(), + duration, + sample_rate, + } + } + + fn filter_by_time_range( + &self, + times: Vec, + values: Vec, + time_range: Option<(f64, f64)>, + ) -> (Vec, Vec) { + if let Some((start, end)) = time_range { + let filtered: Vec<(f64, f64)> = times + .into_iter() + .zip(values) + .filter(|(t, _)| *t >= start && *t <= end) + .collect(); + + let times: Vec = filtered.iter().map(|(t, _)| *t).collect(); + let values: Vec = filtered.iter().map(|(_, v)| *v).collect(); + (times, values) + } else { + (times, values) + } + } + + fn compute_stats(&self, times: &[f64], values: &[f64]) -> ChannelStats { + if values.is_empty() { + return ChannelStats { + min: 0.0, + max: 0.0, + mean: 0.0, + std_dev: 0.0, + median: 0.0, + count: 0, + min_time: 0.0, + max_time: 0.0, + }; + } + + let mut min = f64::INFINITY; + let mut max = f64::NEG_INFINITY; + let mut min_time = 0.0; + let mut max_time = 0.0; + let mut sum = 0.0; + + for (i, &v) in values.iter().enumerate() { + if v < min { + min = v; + min_time = times.get(i).copied().unwrap_or(0.0); + } + if v > max { + max = v; + max_time = times.get(i).copied().unwrap_or(0.0); + } + sum += v; + } + + let mean = sum / values.len() as f64; + + let variance = + values.iter().map(|v| (v - mean).powi(2)).sum::() / values.len() as f64; + let std_dev = variance.sqrt(); + + let mut sorted = values.to_vec(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let median = if sorted.len() % 2 == 0 { + (sorted[sorted.len() / 2 - 1] + sorted[sorted.len() / 2]) / 2.0 + } else { + sorted[sorted.len() / 2] + }; + + ChannelStats { + min, + max, + mean, + std_dev, + median, + count: values.len(), + min_time, + max_time, + } + } + + fn find_peaks_in_data(&self, times: &[f64], values: &[f64], min_prominence: f64) -> Vec { + let mut peaks = Vec::new(); + + if values.len() < 3 { + return peaks; + } + + // Simple peak detection: local maxima + for i in 1..values.len() - 1 { + if values[i] > values[i - 1] && values[i] > values[i + 1] { + // Calculate prominence (height above surrounding valleys) + let left_min = values[..i] + .iter() + .rev() + .take(10) + .cloned() + .fold(f64::INFINITY, f64::min); + let right_min = values[i + 1..] + .iter() + .take(10) + .cloned() + .fold(f64::INFINITY, f64::min); + let prominence = values[i] - left_min.max(right_min); + + if prominence >= min_prominence { + peaks.push(Peak { + time: times[i], + value: values[i], + prominence, + }); + } + } + } + + peaks + } + + fn compute_correlation(&self, a: &[f64], b: &[f64]) -> f64 { + let n = a.len() as f64; + let mean_a = a.iter().sum::() / n; + let mean_b = b.iter().sum::() / n; + + let mut cov = 0.0; + let mut var_a = 0.0; + let mut var_b = 0.0; + + for (ai, bi) in a.iter().zip(b.iter()) { + let da = ai - mean_a; + let db = bi - mean_b; + cov += da * db; + var_a += da * da; + var_b += db * db; + } + + if var_a == 0.0 || var_b == 0.0 { + return 0.0; + } + + cov / (var_a.sqrt() * var_b.sqrt()) + } + + fn interpret_correlation(&self, r: f64) -> String { + let abs_r = r.abs(); + let strength = if abs_r >= 0.9 { + "very strong" + } else if abs_r >= 0.7 { + "strong" + } else if abs_r >= 0.5 { + "moderate" + } else if abs_r >= 0.3 { + "weak" + } else { + "very weak or no" + }; + + let direction = if r > 0.0 { "positive" } else { "negative" }; + + format!( + "{} {} correlation (r={:.3})", + strength.chars().next().unwrap().to_uppercase().to_string() + &strength[1..], + direction, + r + ) + } +} diff --git a/src/ipc/mod.rs b/src/ipc/mod.rs new file mode 100644 index 0000000..9ea72f7 --- /dev/null +++ b/src/ipc/mod.rs @@ -0,0 +1,14 @@ +//! Inter-process communication module for UltraLog MCP integration +//! +//! This module defines the protocol for communication between the UltraLog GUI +//! and the MCP server, allowing Claude to control the running application. + +pub mod commands; +pub mod handler; +pub mod server; + +pub use commands::{IpcCommand, IpcResponse, ChannelStats, FileInfo, ChannelInfo}; +pub use server::IpcServer; + +/// Default port for the IPC server +pub const DEFAULT_IPC_PORT: u16 = 52384; diff --git a/src/ipc/server.rs b/src/ipc/server.rs new file mode 100644 index 0000000..a87b37c --- /dev/null +++ b/src/ipc/server.rs @@ -0,0 +1,167 @@ +//! TCP server for receiving IPC commands from the MCP server +//! +//! This server runs in a background thread and communicates with the GUI +//! via channels, allowing the main eframe event loop to process commands. + +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::thread; + +use super::commands::{IpcCommand, IpcResponse}; +use super::DEFAULT_IPC_PORT; + +/// IPC Server that listens for commands from the MCP server +pub struct IpcServer { + /// Receiver for incoming commands (polled by the GUI) + command_rx: Receiver<(IpcCommand, Sender)>, + /// Port the server is listening on + port: u16, + /// Whether the server is running + is_running: bool, +} + +impl IpcServer { + /// Start a new IPC server on the default port + pub fn start() -> Result { + Self::start_on_port(DEFAULT_IPC_PORT) + } + + /// Start a new IPC server on a specific port + pub fn start_on_port(port: u16) -> Result { + let listener = TcpListener::bind(format!("127.0.0.1:{}", port)) + .map_err(|e| format!("Failed to bind to port {}: {}", port, e))?; + + // Set non-blocking so we can check for shutdown + listener + .set_nonblocking(true) + .map_err(|e| format!("Failed to set non-blocking: {}", e))?; + + let (command_tx, command_rx) = mpsc::channel(); + + // Spawn the listener thread + thread::spawn(move || { + Self::listener_loop(listener, command_tx); + }); + + tracing::info!("IPC server started on port {}", port); + + Ok(Self { + command_rx, + port, + is_running: true, + }) + } + + /// Get the port the server is listening on + pub fn port(&self) -> u16 { + self.port + } + + /// Check if there's a pending command and return it + pub fn poll_command(&self) -> Option<(IpcCommand, Sender)> { + self.command_rx.try_recv().ok() + } + + /// Check if the server is running + pub fn is_running(&self) -> bool { + self.is_running + } + + /// Main listener loop (runs in background thread) + fn listener_loop(listener: TcpListener, command_tx: Sender<(IpcCommand, Sender)>) { + loop { + match listener.accept() { + Ok((stream, addr)) => { + tracing::info!("MCP client connected from {}", addr); + let tx = command_tx.clone(); + thread::spawn(move || { + Self::handle_connection(stream, tx); + }); + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + // No connection available, sleep briefly + thread::sleep(std::time::Duration::from_millis(100)); + } + Err(e) => { + tracing::error!("Error accepting connection: {}", e); + thread::sleep(std::time::Duration::from_millis(100)); + } + } + } + } + + /// Handle a single client connection + fn handle_connection( + mut stream: TcpStream, + command_tx: Sender<(IpcCommand, Sender)>, + ) { + let peer_addr = stream.peer_addr().ok(); + + // Set timeouts + let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(30))); + let _ = stream.set_write_timeout(Some(std::time::Duration::from_secs(10))); + + let reader = BufReader::new(stream.try_clone().expect("Failed to clone stream")); + + for line in reader.lines() { + let line = match line { + Ok(l) => l, + Err(e) => { + tracing::debug!("Connection closed: {}", e); + break; + } + }; + + if line.trim().is_empty() { + continue; + } + + // Parse the command + let command: IpcCommand = match serde_json::from_str(&line) { + Ok(cmd) => cmd, + Err(e) => { + let response = IpcResponse::error(format!("Invalid command JSON: {}", e)); + let _ = Self::send_response(&mut stream, &response); + continue; + } + }; + + tracing::debug!("Received command: {:?}", command); + + // Create a channel for the response + let (response_tx, response_rx) = mpsc::channel(); + + // Send the command to the GUI thread + if command_tx.send((command, response_tx)).is_err() { + let response = IpcResponse::error("GUI is not responding"); + let _ = Self::send_response(&mut stream, &response); + break; + } + + // Wait for the response from the GUI + let response = match response_rx.recv_timeout(std::time::Duration::from_secs(30)) { + Ok(resp) => resp, + Err(_) => IpcResponse::error("Timeout waiting for GUI response"), + }; + + if Self::send_response(&mut stream, &response).is_err() { + break; + } + } + + if let Some(addr) = peer_addr { + tracing::info!("MCP client disconnected: {}", addr); + } + } + + /// Send a response to the client + fn send_response(stream: &mut TcpStream, response: &IpcResponse) -> Result<(), std::io::Error> { + let json = serde_json::to_string(response).unwrap_or_else(|_| { + r#"{"status":"Error","data":{"message":"Failed to serialize response"}}"#.to_string() + }); + writeln!(stream, "{}", json)?; + stream.flush()?; + Ok(()) + } +} diff --git a/src/lib.rs b/src/lib.rs index 6e3ff55..4bb2a45 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,6 +27,8 @@ pub mod analytics; pub mod app; pub mod computed; pub mod expression; +pub mod ipc; +pub mod mcp; pub mod normalize; pub mod parsers; pub mod state; diff --git a/src/mcp/client.rs b/src/mcp/client.rs new file mode 100644 index 0000000..76e5c2b --- /dev/null +++ b/src/mcp/client.rs @@ -0,0 +1,117 @@ +//! TCP client for communicating with the UltraLog GUI's IPC server + +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpStream; +use std::sync::Mutex; +use std::time::Duration; + +use crate::ipc::commands::{IpcCommand, IpcResponse}; +use crate::ipc::DEFAULT_IPC_PORT; + +/// Client for communicating with the UltraLog GUI +pub struct GuiClient { + stream: Mutex>, + port: u16, +} + +impl GuiClient { + /// Create a new GUI client + pub fn new() -> Self { + Self { + stream: Mutex::new(None), + port: DEFAULT_IPC_PORT, + } + } + + /// Create a new GUI client with a specific port + pub fn with_port(port: u16) -> Self { + Self { + stream: Mutex::new(None), + port, + } + } + + /// Connect to the GUI if not already connected + fn ensure_connected(&self) -> Result<(), String> { + let mut stream = self.stream.lock().map_err(|e| format!("Lock error: {}", e))?; + + if stream.is_some() { + return Ok(()); + } + + let addr = format!("127.0.0.1:{}", self.port); + let new_stream = TcpStream::connect(&addr) + .map_err(|e| format!("Failed to connect to UltraLog GUI at {}: {}", addr, e))?; + + new_stream + .set_read_timeout(Some(Duration::from_secs(30))) + .map_err(|e| format!("Failed to set read timeout: {}", e))?; + new_stream + .set_write_timeout(Some(Duration::from_secs(10))) + .map_err(|e| format!("Failed to set write timeout: {}", e))?; + + *stream = Some(new_stream); + Ok(()) + } + + /// Send a command to the GUI and get a response + pub fn send_command(&self, command: IpcCommand) -> Result { + self.ensure_connected()?; + + let mut stream_guard = self.stream.lock().map_err(|e| format!("Lock error: {}", e))?; + + // Take the stream out temporarily + let mut stream = stream_guard + .take() + .ok_or_else(|| "Not connected".to_string())?; + + // Serialize and send the command + let json = serde_json::to_string(&command) + .map_err(|e| format!("Failed to serialize command: {}", e))?; + + if let Err(e) = writeln!(stream, "{}", json) { + // Connection lost, don't put it back + return Err(format!("Failed to send command: {}", e)); + } + + if let Err(e) = stream.flush() { + return Err(format!("Failed to flush: {}", e)); + } + + // Read the response + let mut reader = BufReader::new(stream.try_clone().map_err(|e| format!("Clone error: {}", e))?); + let mut response_line = String::new(); + + if let Err(e) = reader.read_line(&mut response_line) { + return Err(format!("Failed to read response: {}", e)); + } + + // Put the stream back + *stream_guard = Some(stream); + + // Parse the response + serde_json::from_str(&response_line) + .map_err(|e| format!("Failed to parse response: {}", e)) + } + + /// Check if the GUI is running and responsive + pub fn ping(&self) -> bool { + match self.send_command(IpcCommand::Ping) { + Ok(IpcResponse::Ok(_)) => true, + _ => false, + } + } + + /// Disconnect from the GUI + pub fn disconnect(&self) { + if let Ok(mut stream) = self.stream.lock() { + *stream = None; + } + } +} + +impl Default for GuiClient { + fn default() -> Self { + Self::new() + } +} diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs new file mode 100644 index 0000000..7bf9130 --- /dev/null +++ b/src/mcp/mod.rs @@ -0,0 +1,10 @@ +//! MCP (Model Context Protocol) server module for UltraLog +//! +//! This module implements an MCP server that allows LLMs like Claude to +//! interact with the UltraLog application, controlling channel visualization, +//! computing derived channels, and analyzing ECU log data. + +pub mod client; +pub mod server; + +pub use server::UltraLogMcpServer; diff --git a/src/mcp/server.rs b/src/mcp/server.rs new file mode 100644 index 0000000..7030c01 --- /dev/null +++ b/src/mcp/server.rs @@ -0,0 +1,554 @@ +//! MCP Server implementation for UltraLog +//! +//! This module implements the MCP protocol server that allows Claude to +//! interact with UltraLog through the Model Context Protocol. + +use rmcp::handler::server::router::tool::ToolRouter; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::{CallToolResult, Content, ErrorCode, ErrorData as McpError, Implementation, ProtocolVersion, ServerCapabilities, ServerInfo}; +use rmcp::schemars::JsonSchema; +use rmcp::{tool, tool_handler, tool_router, ServerHandler}; +use serde::Deserialize; +use std::borrow::Cow; +use std::sync::Arc; + +use super::client::GuiClient; +use crate::ipc::commands::{IpcCommand, IpcResponse, ResponseData}; + +/// UltraLog MCP Server +#[derive(Clone)] +pub struct UltraLogMcpServer { + client: Arc, + tool_router: ToolRouter, +} + +impl UltraLogMcpServer { + pub fn new() -> Self { + Self { + client: Arc::new(GuiClient::new()), + tool_router: Self::tool_router(), + } + } + + pub fn with_port(port: u16) -> Self { + Self { + client: Arc::new(GuiClient::with_port(port)), + tool_router: Self::tool_router(), + } + } + + /// Run the MCP server on stdio + pub async fn run_stdio(self) -> Result<(), Box> { + use rmcp::ServiceExt; + + let transport = rmcp::transport::stdio(); + let server = self.serve(transport).await?; + server.waiting().await?; + Ok(()) + } + + fn send_command(&self, command: IpcCommand) -> Result { + self.client.send_command(command) + } + + fn mcp_error(message: impl Into) -> McpError { + McpError { + code: ErrorCode(-32603), + message: Cow::Owned(message.into()), + data: None, + } + } +} + +impl Default for UltraLogMcpServer { + fn default() -> Self { + Self::new() + } +} + +// ============================================================================ +// Tool Input Types +// ============================================================================ + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct LoadFileRequest { + #[schemars(description = "Path to the ECU log file to load")] + pub path: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct FileIdRequest { + #[schemars(description = "ID of the loaded file (use get_state to see loaded files)")] + pub file_id: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ChannelRequest { + #[schemars(description = "ID of the loaded file")] + pub file_id: String, + #[schemars(description = "Name of the channel")] + pub channel_name: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ChannelDataRequest { + #[schemars(description = "ID of the loaded file")] + pub file_id: String, + #[schemars(description = "Name of the channel")] + pub channel_name: String, + #[schemars(description = "Optional start time in seconds")] + #[serde(default)] + pub start_time: Option, + #[schemars(description = "Optional end time in seconds")] + #[serde(default)] + pub end_time: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateComputedChannelRequest { + #[schemars(description = "Name for the computed channel")] + pub name: String, + #[schemars(description = "Mathematical formula (e.g., 'RPM * 0.5 + Boost'). Use channel names as variables.")] + pub formula: String, + #[schemars(description = "Unit for the computed channel (e.g., 'kPa', 'RPM', 'deg')")] + pub unit: String, + #[schemars(description = "Optional description")] + #[serde(default)] + pub description: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct EvaluateFormulaRequest { + #[schemars(description = "ID of the loaded file")] + pub file_id: String, + #[schemars(description = "Mathematical formula to evaluate")] + pub formula: String, + #[schemars(description = "Optional start time in seconds")] + #[serde(default)] + pub start_time: Option, + #[schemars(description = "Optional end time in seconds")] + #[serde(default)] + pub end_time: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SetTimeRangeRequest { + #[schemars(description = "Start time in seconds")] + pub start: f64, + #[schemars(description = "End time in seconds")] + pub end: f64, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SetCursorRequest { + #[schemars(description = "Cursor position in seconds")] + pub time: f64, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct PlayRequest { + #[schemars(description = "Playback speed multiplier (0.25 to 8.0, default 1.0)")] + #[serde(default)] + pub speed: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct FindPeaksRequest { + #[schemars(description = "ID of the loaded file")] + pub file_id: String, + #[schemars(description = "Name of the channel")] + pub channel_name: String, + #[schemars(description = "Minimum prominence for peak detection (default 0.1)")] + #[serde(default)] + pub min_prominence: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CorrelateChannelsRequest { + #[schemars(description = "ID of the loaded file")] + pub file_id: String, + #[schemars(description = "First channel name")] + pub channel_a: String, + #[schemars(description = "Second channel name")] + pub channel_b: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ShowScatterPlotRequest { + #[schemars(description = "ID of the loaded file")] + pub file_id: String, + #[schemars(description = "Channel for X axis")] + pub x_channel: String, + #[schemars(description = "Channel for Y axis")] + pub y_channel: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DeleteComputedChannelRequest { + #[schemars(description = "Name of the computed channel to delete")] + pub name: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct EmptyRequest {} + +// ============================================================================ +// Tool Implementations +// ============================================================================ + +#[tool_router] +impl UltraLogMcpServer { + #[tool(description = "Get the current state of UltraLog including loaded files, selected channels, cursor position, and view mode.")] + async fn get_state(&self, Parameters(_): Parameters) -> Result { + match self.send_command(IpcCommand::GetState) { + Ok(IpcResponse::Ok(Some(ResponseData::State(state)))) => { + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&state).unwrap_or_default(), + )])) + } + Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), + Err(e) => Err(Self::mcp_error(e)), + _ => Err(Self::mcp_error("Unexpected response")), + } + } + + #[tool(description = "Load an ECU log file. Supports Haltech CSV, ECUMaster CSV, RomRaider CSV, Speeduino/rusEFI MLG, AiM XRK/DRK, and Link LLG formats.")] + async fn load_file(&self, Parameters(req): Parameters) -> Result { + match self.send_command(IpcCommand::LoadFile { path: req.path }) { + Ok(IpcResponse::Ok(Some(ResponseData::FileLoaded(info)))) => { + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&info).unwrap_or_default(), + )])) + } + Ok(IpcResponse::Ok(Some(ResponseData::Ack))) => { + Ok(CallToolResult::success(vec![Content::text( + "File is being loaded. Use get_state to check when ready.", + )])) + } + Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), + Err(e) => Err(Self::mcp_error(e)), + _ => Err(Self::mcp_error("Unexpected response")), + } + } + + #[tool(description = "Close a loaded file.")] + async fn close_file(&self, Parameters(req): Parameters) -> Result { + match self.send_command(IpcCommand::CloseFile { file_id: req.file_id }) { + Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text("File closed")])), + Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), + Err(e) => Err(Self::mcp_error(e)), + } + } + + #[tool(description = "List all available channels in a loaded file, including computed channels.")] + async fn list_channels(&self, Parameters(req): Parameters) -> Result { + match self.send_command(IpcCommand::ListChannels { file_id: req.file_id }) { + Ok(IpcResponse::Ok(Some(ResponseData::Channels(channels)))) => { + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&channels).unwrap_or_default(), + )])) + } + Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), + Err(e) => Err(Self::mcp_error(e)), + _ => Err(Self::mcp_error("Unexpected response")), + } + } + + #[tool(description = "Get time series data for a specific channel. Optionally filter by time range.")] + async fn get_channel_data(&self, Parameters(req): Parameters) -> Result { + let time_range = match (req.start_time, req.end_time) { + (Some(start), Some(end)) => Some((start, end)), + _ => None, + }; + + match self.send_command(IpcCommand::GetChannelData { + file_id: req.file_id, + channel_name: req.channel_name, + time_range, + }) { + Ok(IpcResponse::Ok(Some(ResponseData::ChannelData { times, values }))) => { + let result = serde_json::json!({ + "sample_count": times.len(), + "times": times, + "values": values + }); + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&result).unwrap_or_default(), + )])) + } + Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), + Err(e) => Err(Self::mcp_error(e)), + _ => Err(Self::mcp_error("Unexpected response")), + } + } + + #[tool(description = "Get statistics (min, max, mean, std_dev, median) for a channel.")] + async fn get_channel_stats(&self, Parameters(req): Parameters) -> Result { + let time_range = match (req.start_time, req.end_time) { + (Some(start), Some(end)) => Some((start, end)), + _ => None, + }; + + match self.send_command(IpcCommand::GetChannelStats { + file_id: req.file_id, + channel_name: req.channel_name, + time_range, + }) { + Ok(IpcResponse::Ok(Some(ResponseData::Stats(stats)))) => { + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&stats).unwrap_or_default(), + )])) + } + Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), + Err(e) => Err(Self::mcp_error(e)), + _ => Err(Self::mcp_error("Unexpected response")), + } + } + + #[tool(description = "Add a channel to the chart display. The user will see this channel visualized in the UltraLog GUI.")] + async fn select_channel(&self, Parameters(req): Parameters) -> Result { + match self.send_command(IpcCommand::SelectChannel { + file_id: req.file_id, + channel_name: req.channel_name, + }) { + Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text("Channel selected")])), + Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), + Err(e) => Err(Self::mcp_error(e)), + } + } + + #[tool(description = "Remove a channel from the chart display.")] + async fn deselect_channel(&self, Parameters(req): Parameters) -> Result { + match self.send_command(IpcCommand::DeselectChannel { + file_id: req.file_id, + channel_name: req.channel_name, + }) { + Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text("Channel deselected")])), + Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), + Err(e) => Err(Self::mcp_error(e)), + } + } + + #[tool(description = "Remove all channels from the chart display.")] + async fn deselect_all_channels(&self, Parameters(_): Parameters) -> Result { + match self.send_command(IpcCommand::DeselectAllChannels) { + Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text("All channels deselected")])), + Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), + Err(e) => Err(Self::mcp_error(e)), + } + } + + #[tool(description = "Create a new computed channel from a mathematical formula. Supports: +, -, *, /, ^, sin, cos, tan, sqrt, abs, ln, log, min, max. Time-shifting: RPM[-1] (previous sample), RPM@-0.1s (100ms ago).")] + async fn create_computed_channel(&self, Parameters(req): Parameters) -> Result { + let name = req.name.clone(); + match self.send_command(IpcCommand::CreateComputedChannel { + name: req.name, + formula: req.formula, + unit: req.unit, + description: req.description, + }) { + Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text( + format!("Computed channel '{}' created", name), + )])), + Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), + Err(e) => Err(Self::mcp_error(e)), + } + } + + #[tool(description = "Delete a computed channel.")] + async fn delete_computed_channel(&self, Parameters(req): Parameters) -> Result { + match self.send_command(IpcCommand::DeleteComputedChannel { name: req.name }) { + Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text("Computed channel deleted")])), + Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), + Err(e) => Err(Self::mcp_error(e)), + } + } + + #[tool(description = "List all saved computed channel templates.")] + async fn list_computed_channels(&self, Parameters(_): Parameters) -> Result { + match self.send_command(IpcCommand::ListComputedChannels) { + Ok(IpcResponse::Ok(Some(ResponseData::ComputedChannels(channels)))) => { + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&channels).unwrap_or_default(), + )])) + } + Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), + Err(e) => Err(Self::mcp_error(e)), + _ => Err(Self::mcp_error("Unexpected response")), + } + } + + #[tool(description = "Evaluate a mathematical formula against the log data without creating a permanent channel. Returns the computed values and statistics.")] + async fn evaluate_formula(&self, Parameters(req): Parameters) -> Result { + let time_range = match (req.start_time, req.end_time) { + (Some(start), Some(end)) => Some((start, end)), + _ => None, + }; + + match self.send_command(IpcCommand::EvaluateFormula { + file_id: req.file_id, + formula: req.formula, + time_range, + }) { + Ok(IpcResponse::Ok(Some(ResponseData::FormulaResult { times, values, stats }))) => { + let result = serde_json::json!({ + "sample_count": times.len(), + "stats": stats, + "times": times, + "values": values + }); + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&result).unwrap_or_default(), + )])) + } + Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), + Err(e) => Err(Self::mcp_error(e)), + _ => Err(Self::mcp_error("Unexpected response")), + } + } + + #[tool(description = "Set the visible time range on the chart. Use this to zoom into a specific time window.")] + async fn set_time_range(&self, Parameters(req): Parameters) -> Result { + match self.send_command(IpcCommand::SetTimeRange { start: req.start, end: req.end }) { + Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text("Time range set")])), + Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), + Err(e) => Err(Self::mcp_error(e)), + } + } + + #[tool(description = "Set the cursor position on the timeline. The user will see channel values at this time.")] + async fn set_cursor(&self, Parameters(req): Parameters) -> Result { + match self.send_command(IpcCommand::SetCursor { time: req.time }) { + Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text("Cursor set")])), + Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), + Err(e) => Err(Self::mcp_error(e)), + } + } + + #[tool(description = "Start playback of the log data. The cursor will move through time.")] + async fn play(&self, Parameters(req): Parameters) -> Result { + match self.send_command(IpcCommand::Play { speed: req.speed }) { + Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text("Playback started")])), + Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), + Err(e) => Err(Self::mcp_error(e)), + } + } + + #[tool(description = "Pause playback.")] + async fn pause(&self, Parameters(_): Parameters) -> Result { + match self.send_command(IpcCommand::Pause) { + Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text("Playback paused")])), + Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), + Err(e) => Err(Self::mcp_error(e)), + } + } + + #[tool(description = "Stop playback and reset cursor to the start.")] + async fn stop(&self, Parameters(_): Parameters) -> Result { + match self.send_command(IpcCommand::Stop) { + Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text("Playback stopped")])), + Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), + Err(e) => Err(Self::mcp_error(e)), + } + } + + #[tool(description = "Get channel values at the current cursor position.")] + async fn get_cursor_values(&self, Parameters(req): Parameters) -> Result { + match self.send_command(IpcCommand::GetCursorValues { file_id: req.file_id }) { + Ok(IpcResponse::Ok(Some(ResponseData::CursorValues(values)))) => { + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&values).unwrap_or_default(), + )])) + } + Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), + Err(e) => Err(Self::mcp_error(e)), + _ => Err(Self::mcp_error("Unexpected response")), + } + } + + #[tool(description = "Find peaks (local maxima) in a channel. Useful for finding acceleration events, boost spikes, etc.")] + async fn find_peaks(&self, Parameters(req): Parameters) -> Result { + match self.send_command(IpcCommand::FindPeaks { + file_id: req.file_id, + channel_name: req.channel_name, + min_prominence: req.min_prominence, + }) { + Ok(IpcResponse::Ok(Some(ResponseData::Peaks(peaks)))) => { + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&peaks).unwrap_or_default(), + )])) + } + Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), + Err(e) => Err(Self::mcp_error(e)), + _ => Err(Self::mcp_error("Unexpected response")), + } + } + + #[tool(description = "Calculate the correlation between two channels. Returns Pearson correlation coefficient and interpretation.")] + async fn correlate_channels(&self, Parameters(req): Parameters) -> Result { + match self.send_command(IpcCommand::CorrelateChannels { + file_id: req.file_id, + channel_a: req.channel_a, + channel_b: req.channel_b, + }) { + Ok(IpcResponse::Ok(Some(ResponseData::Correlation { coefficient, interpretation }))) => { + let result = serde_json::json!({ + "coefficient": coefficient, + "interpretation": interpretation + }); + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&result).unwrap_or_default(), + )])) + } + Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), + Err(e) => Err(Self::mcp_error(e)), + _ => Err(Self::mcp_error("Unexpected response")), + } + } + + #[tool(description = "Switch to scatter plot view to visualize correlation between two channels.")] + async fn show_scatter_plot(&self, Parameters(req): Parameters) -> Result { + match self.send_command(IpcCommand::ShowScatterPlot { + file_id: req.file_id, + x_channel: req.x_channel, + y_channel: req.y_channel, + }) { + Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text("Scatter plot displayed")])), + Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), + Err(e) => Err(Self::mcp_error(e)), + } + } + + #[tool(description = "Switch back to time series chart view.")] + async fn show_chart(&self, Parameters(_): Parameters) -> Result { + match self.send_command(IpcCommand::ShowChart) { + Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text("Chart view displayed")])), + Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), + Err(e) => Err(Self::mcp_error(e)), + } + } +} + +#[tool_handler] +impl ServerHandler for UltraLogMcpServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + protocol_version: ProtocolVersion::V_2024_11_05, + capabilities: ServerCapabilities::builder().enable_tools().build(), + server_info: Implementation { + name: "ultralog".into(), + version: env!("CARGO_PKG_VERSION").into(), + title: None, + icons: None, + website_url: None, + }, + instructions: Some( + "UltraLog MCP Server - Control the UltraLog ECU log viewer application. \ + Use get_state to see loaded files and current view. \ + Load files, select channels to display, create computed channels, \ + and analyze ECU telemetry data." + .to_string(), + ), + } + } +} From 6c011de7a2c614137d72869bff315d313e140dea Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 31 Dec 2025 03:04:52 +0000 Subject: [PATCH 2/6] Embed MCP HTTP server into GUI app (no separate binary needed) Changes the MCP server architecture so UltraLog GUI embeds and launches the MCP server itself. Claude Desktop now connects via: http://localhost:52453/mcp Key changes: - Switch from stdio to streamable HTTP transport (rmcp 0.12) - Add axum for HTTP routing, tokio for async runtime - Embed MCP server in GUI lifecycle (starts on app launch) - Remove separate ultralog-mcp binary - Port 52453 = 5-2-4-5-3, a nod to I5 engine firing order 1-2-4-5-3 (in dynamic port range 49152-65535 for OS compatibility) - Add McpServerHandle for clean shutdown --- Cargo.lock | 221 +++++++++++++++++++++++ Cargo.toml | 7 +- src/app.rs | 25 ++- src/bin/ultralog_mcp.rs | 53 ------ src/ipc/commands.rs | 5 +- src/ipc/handler.rs | 23 ++- src/ipc/mod.rs | 2 +- src/mcp/client.rs | 24 ++- src/mcp/mod.rs | 5 +- src/mcp/server.rs | 379 ++++++++++++++++++++++++++++++++-------- 10 files changed, 588 insertions(+), 156 deletions(-) delete mode 100644 src/bin/ultralog_mcp.rs diff --git a/Cargo.lock b/Cargo.lock index 57e54f0..62cf1d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -338,6 +338,58 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.22.1" @@ -1686,12 +1738,78 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "iana-time-zone" version = "0.1.64" @@ -2052,6 +2170,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "md5" version = "0.7.0" @@ -3117,18 +3241,27 @@ checksum = "528d42f8176e6e5e71ea69182b17d1d0a19a6b3b894b564678b74cd7cab13cfa" dependencies = [ "async-trait", "base64", + "bytes", "chrono", "futures", + "http", + "http-body", + "http-body-util", "pastey", "pin-project-lite", + "rand 0.9.2", "rmcp-macros", "schemars", "serde", "serde_json", + "sse-stream", "thiserror 2.0.17", "tokio", + "tokio-stream", "tokio-util", + "tower-service", "tracing", + "uuid", ] [[package]] @@ -3236,6 +3369,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + [[package]] name = "same-file" version = "1.0.6" @@ -3343,6 +3482,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -3363,6 +3513,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3514,6 +3676,19 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "sse-stream" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb4dc4d33c68ec1f27d386b5610a351922656e1fdf5c05bbaad930cd1519479a" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3570,6 +3745,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "synstructure" version = "0.13.2" @@ -3737,6 +3918,17 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.17" @@ -3801,6 +3993,34 @@ version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -3902,6 +4122,7 @@ name = "ultralog" version = "2.0.0" dependencies = [ "anyhow", + "axum", "dirs", "eframe", "egui_extras", diff --git a/Cargo.toml b/Cargo.toml index 88c3d4c..1557689 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,10 +19,6 @@ path = "src/main.rs" name = "test_parser" path = "src/bin/test_parser.rs" -[[bin]] -name = "ultralog-mcp" -path = "src/bin/ultralog_mcp.rs" - [dependencies] # GUI Framework eframe = { version = "0.33", default-features = false, features = [ @@ -77,8 +73,9 @@ tracing = "0.1" tracing-subscriber = "0.3" # MCP Server (Model Context Protocol) -rmcp = { version = "0.12", features = ["server", "transport-io", "macros"] } +rmcp = { version = "0.12", features = ["server", "transport-streamable-http-server", "macros"] } tokio = { version = "1", features = ["full"] } +axum = "0.8" schemars = "1.0" # UUID generation for anonymous user IDs diff --git a/src/app.rs b/src/app.rs index 71f7c90..864389b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -15,6 +15,7 @@ use crate::analysis::{AnalysisResult, AnalyzerRegistry}; use crate::analytics; use crate::computed::{ComputedChannel, ComputedChannelLibrary, FormulaEditorState}; use crate::ipc::IpcServer; +use crate::mcp::{start_mcp_server, McpServerHandle, DEFAULT_MCP_PORT}; use crate::parsers::{ Aim, EcuMaster, EcuType, Emerald, Haltech, Link, Parseable, RomRaider, Speeduino, }; @@ -143,6 +144,8 @@ pub struct UltraLogApp { // === MCP Integration === /// IPC server for MCP integration (allows Claude to control the app) ipc_server: Option, + /// MCP HTTP server handle (embedded server for Claude Desktop connection) + mcp_server: Option, } impl Default for UltraLogApp { @@ -197,6 +200,7 @@ impl Default for UltraLogApp { show_analysis_panel: false, analysis_selected_category: None, ipc_server: None, + mcp_server: None, } } } @@ -238,9 +242,11 @@ impl UltraLogApp { // Start the IPC server for MCP integration let mut app = Self::default(); + let mut ipc_port = crate::ipc::DEFAULT_IPC_PORT; match IpcServer::start() { Ok(server) => { - tracing::info!("MCP IPC server started on port {}", server.port()); + ipc_port = server.port(); + tracing::info!("MCP IPC server started on port {}", ipc_port); app.ipc_server = Some(server); } Err(e) => { @@ -248,6 +254,23 @@ impl UltraLogApp { } } + // Start the MCP HTTP server (embedded, for Claude Desktop connection) + if app.ipc_server.is_some() { + match start_mcp_server(DEFAULT_MCP_PORT, ipc_port) { + Ok(handle) => { + tracing::info!( + "MCP HTTP server started at {} (port {} = 5-2-4-5-3, I5 firing order tribute)", + handle.url(), + handle.port() + ); + app.mcp_server = Some(handle); + } + Err(e) => { + tracing::warn!("Failed to start MCP HTTP server: {}", e); + } + } + } + app } diff --git a/src/bin/ultralog_mcp.rs b/src/bin/ultralog_mcp.rs deleted file mode 100644 index 6e8b25b..0000000 --- a/src/bin/ultralog_mcp.rs +++ /dev/null @@ -1,53 +0,0 @@ -//! UltraLog MCP Server Binary -//! -//! This is the MCP (Model Context Protocol) server for UltraLog. It allows -//! LLMs like Claude to interact with the UltraLog application through the -//! standardized MCP protocol. -//! -//! # Usage -//! -//! 1. Start the UltraLog GUI application (it will start the IPC server) -//! 2. Configure Claude Desktop or Claude Code to use this MCP server -//! 3. Claude can now control UltraLog through the MCP tools -//! -//! # Configuration -//! -//! Add to your Claude Desktop config (`~/.config/claude-desktop/config.json`): -//! -//! ```json -//! { -//! "mcpServers": { -//! "ultralog": { -//! "command": "/path/to/ultralog-mcp", -//! "args": [] -//! } -//! } -//! } -//! ``` - -use ultralog::mcp::UltraLogMcpServer; - -#[tokio::main] -async fn main() -> Result<(), Box> { - // Initialize simple logging to stderr (MCP uses stdio for protocol) - tracing_subscriber::fmt() - .with_writer(std::io::stderr) - .with_max_level(tracing::Level::INFO) - .init(); - - tracing::info!("Starting UltraLog MCP Server v{}", env!("CARGO_PKG_VERSION")); - - // Check for port argument - let port = std::env::args() - .nth(1) - .and_then(|s| s.parse::().ok()) - .unwrap_or(ultralog::ipc::DEFAULT_IPC_PORT); - - let server = UltraLogMcpServer::with_port(port); - - tracing::info!("Connecting to UltraLog GUI on port {}", port); - - server.run_stdio().await?; - - Ok(()) -} diff --git a/src/ipc/commands.rs b/src/ipc/commands.rs index ca7e2b0..5834cb9 100644 --- a/src/ipc/commands.rs +++ b/src/ipc/commands.rs @@ -149,10 +149,7 @@ pub enum ResponseData { Channels(Vec), /// Channel time series data - ChannelData { - times: Vec, - values: Vec, - }, + ChannelData { times: Vec, values: Vec }, /// Channel statistics Stats(ChannelStats), diff --git a/src/ipc/handler.rs b/src/ipc/handler.rs index 5b9a805..8663752 100644 --- a/src/ipc/handler.rs +++ b/src/ipc/handler.rs @@ -267,7 +267,10 @@ impl UltraLogApp { } else { // Check computed channels if let Some(computed) = self.file_computed_channels.get(&file_idx) { - if let Some(c) = computed.iter().find(|c| c.name().eq_ignore_ascii_case(channel_name)) { + if let Some(c) = computed + .iter() + .find(|c| c.name().eq_ignore_ascii_case(channel_name)) + { if let Some(data) = &c.cached_data { let all_times = file.log.get_times_as_f64().to_vec(); self.filter_by_time_range(all_times, data.clone(), time_range) @@ -339,7 +342,10 @@ impl UltraLogApp { } else { // Check computed channels if let Some(computed) = self.file_computed_channels.get(&file_idx) { - if let Some(comp_idx) = computed.iter().position(|c| c.name().eq_ignore_ascii_case(channel_name)) { + if let Some(comp_idx) = computed + .iter() + .position(|c| c.name().eq_ignore_ascii_case(channel_name)) + { let channel_idx = file.log.channels.len() + comp_idx; self.add_channel(file_idx, channel_idx); IpcResponse::ok() @@ -462,7 +468,10 @@ impl UltraLogApp { if let Some(tab_idx) = self.active_tab { let file_idx = self.tabs[tab_idx].file_index; if let Some(computed) = self.file_computed_channels.get_mut(&file_idx) { - if let Some(pos) = computed.iter().position(|c| c.name().eq_ignore_ascii_case(name)) { + if let Some(pos) = computed + .iter() + .position(|c| c.name().eq_ignore_ascii_case(name)) + { computed.remove(pos); } } @@ -500,8 +509,7 @@ impl UltraLogApp { }; let file = &self.files[file_idx]; - let available_channels: Vec = - file.log.channels.iter().map(|c| c.name()).collect(); + let available_channels: Vec = file.log.channels.iter().map(|c| c.name()).collect(); // Validate formula if let Err(e) = expression::validate_formula(formula, &available_channels) { @@ -779,13 +787,12 @@ impl UltraLogApp { let mean = sum / values.len() as f64; - let variance = - values.iter().map(|v| (v - mean).powi(2)).sum::() / values.len() as f64; + let variance = values.iter().map(|v| (v - mean).powi(2)).sum::() / values.len() as f64; let std_dev = variance.sqrt(); let mut sorted = values.to_vec(); sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - let median = if sorted.len() % 2 == 0 { + let median = if sorted.len().is_multiple_of(2) { (sorted[sorted.len() / 2 - 1] + sorted[sorted.len() / 2]) / 2.0 } else { sorted[sorted.len() / 2] diff --git a/src/ipc/mod.rs b/src/ipc/mod.rs index 9ea72f7..c1d8053 100644 --- a/src/ipc/mod.rs +++ b/src/ipc/mod.rs @@ -7,7 +7,7 @@ pub mod commands; pub mod handler; pub mod server; -pub use commands::{IpcCommand, IpcResponse, ChannelStats, FileInfo, ChannelInfo}; +pub use commands::{ChannelInfo, ChannelStats, FileInfo, IpcCommand, IpcResponse}; pub use server::IpcServer; /// Default port for the IPC server diff --git a/src/mcp/client.rs b/src/mcp/client.rs index 76e5c2b..3fd781a 100644 --- a/src/mcp/client.rs +++ b/src/mcp/client.rs @@ -33,7 +33,10 @@ impl GuiClient { /// Connect to the GUI if not already connected fn ensure_connected(&self) -> Result<(), String> { - let mut stream = self.stream.lock().map_err(|e| format!("Lock error: {}", e))?; + let mut stream = self + .stream + .lock() + .map_err(|e| format!("Lock error: {}", e))?; if stream.is_some() { return Ok(()); @@ -58,7 +61,10 @@ impl GuiClient { pub fn send_command(&self, command: IpcCommand) -> Result { self.ensure_connected()?; - let mut stream_guard = self.stream.lock().map_err(|e| format!("Lock error: {}", e))?; + let mut stream_guard = self + .stream + .lock() + .map_err(|e| format!("Lock error: {}", e))?; // Take the stream out temporarily let mut stream = stream_guard @@ -79,7 +85,11 @@ impl GuiClient { } // Read the response - let mut reader = BufReader::new(stream.try_clone().map_err(|e| format!("Clone error: {}", e))?); + let mut reader = BufReader::new( + stream + .try_clone() + .map_err(|e| format!("Clone error: {}", e))?, + ); let mut response_line = String::new(); if let Err(e) = reader.read_line(&mut response_line) { @@ -90,16 +100,12 @@ impl GuiClient { *stream_guard = Some(stream); // Parse the response - serde_json::from_str(&response_line) - .map_err(|e| format!("Failed to parse response: {}", e)) + serde_json::from_str(&response_line).map_err(|e| format!("Failed to parse response: {}", e)) } /// Check if the GUI is running and responsive pub fn ping(&self) -> bool { - match self.send_command(IpcCommand::Ping) { - Ok(IpcResponse::Ok(_)) => true, - _ => false, - } + matches!(self.send_command(IpcCommand::Ping), Ok(IpcResponse::Ok(_))) } /// Disconnect from the GUI diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 7bf9130..1813f37 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -3,8 +3,11 @@ //! This module implements an MCP server that allows LLMs like Claude to //! interact with the UltraLog application, controlling channel visualization, //! computing derived channels, and analyzing ECU log data. +//! +//! The server runs as an HTTP service and Claude Desktop can connect via: +//! `http://localhost:52385/mcp` (default port) pub mod client; pub mod server; -pub use server::UltraLogMcpServer; +pub use server::{start_mcp_server, McpServerHandle, UltraLogMcpServer, DEFAULT_MCP_PORT}; diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 7030c01..588a858 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -2,18 +2,126 @@ //! //! This module implements the MCP protocol server that allows Claude to //! interact with UltraLog through the Model Context Protocol. +//! +//! The server runs as an HTTP service on a configurable port (default 52385) +//! and Claude Desktop can connect to it at `http://localhost:52385/mcp` +use axum::Router; use rmcp::handler::server::router::tool::ToolRouter; use rmcp::handler::server::wrapper::Parameters; -use rmcp::model::{CallToolResult, Content, ErrorCode, ErrorData as McpError, Implementation, ProtocolVersion, ServerCapabilities, ServerInfo}; +use rmcp::model::{ + CallToolResult, Content, ErrorCode, ErrorData as McpError, Implementation, ProtocolVersion, + ServerCapabilities, ServerInfo, +}; use rmcp::schemars::JsonSchema; +use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; +use rmcp::transport::streamable_http_server::StreamableHttpService; use rmcp::{tool, tool_handler, tool_router, ServerHandler}; use serde::Deserialize; use std::borrow::Cow; use std::sync::Arc; +use tokio::sync::oneshot; use super::client::GuiClient; use crate::ipc::commands::{IpcCommand, IpcResponse, ResponseData}; +use crate::ipc::DEFAULT_IPC_PORT; + +/// Default port for the MCP HTTP server +/// Port 52453 = 5-2-4-5-3, a nod to the 1-2-4-5-3 firing order of legendary inline-5 engines +/// (Audi Quattro, RS3, Volvo 5-cylinder, etc.) with a leading 5 to stay in the dynamic port range +pub const DEFAULT_MCP_PORT: u16 = 52453; + +/// Handle to control the running MCP server +pub struct McpServerHandle { + shutdown_tx: Option>, + port: u16, +} + +impl McpServerHandle { + /// Get the port the server is running on + pub fn port(&self) -> u16 { + self.port + } + + /// Get the URL for Claude Desktop configuration + pub fn url(&self) -> String { + format!("http://127.0.0.1:{}/mcp", self.port) + } + + /// Signal the server to shut down + pub fn shutdown(&mut self) { + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(()); + } + } +} + +impl Drop for McpServerHandle { + fn drop(&mut self) { + self.shutdown(); + } +} + +/// Start the MCP HTTP server in a background thread +/// +/// Returns a handle that can be used to get the server URL and shut it down. +pub fn start_mcp_server(mcp_port: u16, ipc_port: u16) -> Result { + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("Failed to create tokio runtime"); + + rt.block_on(async move { + if let Err(e) = run_mcp_http_server(mcp_port, ipc_port, shutdown_rx).await { + tracing::error!("MCP server error: {}", e); + } + }); + }); + + Ok(McpServerHandle { + shutdown_tx: Some(shutdown_tx), + port: mcp_port, + }) +} + +/// Run the MCP HTTP server +async fn run_mcp_http_server( + mcp_port: u16, + ipc_port: u16, + shutdown_rx: oneshot::Receiver<()>, +) -> Result<(), Box> { + // Create the MCP service that creates new server instances for each session + let service = StreamableHttpService::new( + move || Ok(UltraLogMcpServer::with_ipc_port(ipc_port)), + LocalSessionManager::default().into(), + Default::default(), + ); + + // Create the router with the MCP service at /mcp + let router = Router::new().nest_service("/mcp", service); + + // Bind to the port + let addr = format!("127.0.0.1:{}", mcp_port); + let listener = tokio::net::TcpListener::bind(&addr).await?; + + tracing::info!( + "MCP HTTP server started at http://{}/mcp", + listener.local_addr()? + ); + + // Run the server with graceful shutdown + axum::serve(listener, router) + .with_graceful_shutdown(async move { + let _ = shutdown_rx.await; + tracing::info!("MCP HTTP server shutting down"); + }) + .await?; + + Ok(()) +} /// UltraLog MCP Server #[derive(Clone)] @@ -24,29 +132,16 @@ pub struct UltraLogMcpServer { impl UltraLogMcpServer { pub fn new() -> Self { - Self { - client: Arc::new(GuiClient::new()), - tool_router: Self::tool_router(), - } + Self::with_ipc_port(DEFAULT_IPC_PORT) } - pub fn with_port(port: u16) -> Self { + pub fn with_ipc_port(ipc_port: u16) -> Self { Self { - client: Arc::new(GuiClient::with_port(port)), + client: Arc::new(GuiClient::with_port(ipc_port)), tool_router: Self::tool_router(), } } - /// Run the MCP server on stdio - pub async fn run_stdio(self) -> Result<(), Box> { - use rmcp::ServiceExt; - - let transport = rmcp::transport::stdio(); - let server = self.serve(transport).await?; - server.waiting().await?; - Ok(()) - } - fn send_command(&self, command: IpcCommand) -> Result { self.client.send_command(command) } @@ -108,7 +203,9 @@ pub struct ChannelDataRequest { pub struct CreateComputedChannelRequest { #[schemars(description = "Name for the computed channel")] pub name: String, - #[schemars(description = "Mathematical formula (e.g., 'RPM * 0.5 + Boost'). Use channel names as variables.")] + #[schemars( + description = "Mathematical formula (e.g., 'RPM * 0.5 + Boost'). Use channel names as variables." + )] pub formula: String, #[schemars(description = "Unit for the computed channel (e.g., 'kPa', 'RPM', 'deg')")] pub unit: String, @@ -198,8 +295,13 @@ pub struct EmptyRequest {} #[tool_router] impl UltraLogMcpServer { - #[tool(description = "Get the current state of UltraLog including loaded files, selected channels, cursor position, and view mode.")] - async fn get_state(&self, Parameters(_): Parameters) -> Result { + #[tool( + description = "Get the current state of UltraLog including loaded files, selected channels, cursor position, and view mode." + )] + async fn get_state( + &self, + Parameters(_): Parameters, + ) -> Result { match self.send_command(IpcCommand::GetState) { Ok(IpcResponse::Ok(Some(ResponseData::State(state)))) => { Ok(CallToolResult::success(vec![Content::text( @@ -212,8 +314,13 @@ impl UltraLogMcpServer { } } - #[tool(description = "Load an ECU log file. Supports Haltech CSV, ECUMaster CSV, RomRaider CSV, Speeduino/rusEFI MLG, AiM XRK/DRK, and Link LLG formats.")] - async fn load_file(&self, Parameters(req): Parameters) -> Result { + #[tool( + description = "Load an ECU log file. Supports Haltech CSV, ECUMaster CSV, RomRaider CSV, Speeduino/rusEFI MLG, AiM XRK/DRK, and Link LLG formats." + )] + async fn load_file( + &self, + Parameters(req): Parameters, + ) -> Result { match self.send_command(IpcCommand::LoadFile { path: req.path }) { Ok(IpcResponse::Ok(Some(ResponseData::FileLoaded(info)))) => { Ok(CallToolResult::success(vec![Content::text( @@ -232,17 +339,31 @@ impl UltraLogMcpServer { } #[tool(description = "Close a loaded file.")] - async fn close_file(&self, Parameters(req): Parameters) -> Result { - match self.send_command(IpcCommand::CloseFile { file_id: req.file_id }) { - Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text("File closed")])), + async fn close_file( + &self, + Parameters(req): Parameters, + ) -> Result { + match self.send_command(IpcCommand::CloseFile { + file_id: req.file_id, + }) { + Ok(IpcResponse::Ok(_)) => { + Ok(CallToolResult::success(vec![Content::text("File closed")])) + } Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), Err(e) => Err(Self::mcp_error(e)), } } - #[tool(description = "List all available channels in a loaded file, including computed channels.")] - async fn list_channels(&self, Parameters(req): Parameters) -> Result { - match self.send_command(IpcCommand::ListChannels { file_id: req.file_id }) { + #[tool( + description = "List all available channels in a loaded file, including computed channels." + )] + async fn list_channels( + &self, + Parameters(req): Parameters, + ) -> Result { + match self.send_command(IpcCommand::ListChannels { + file_id: req.file_id, + }) { Ok(IpcResponse::Ok(Some(ResponseData::Channels(channels)))) => { Ok(CallToolResult::success(vec![Content::text( serde_json::to_string_pretty(&channels).unwrap_or_default(), @@ -254,8 +375,13 @@ impl UltraLogMcpServer { } } - #[tool(description = "Get time series data for a specific channel. Optionally filter by time range.")] - async fn get_channel_data(&self, Parameters(req): Parameters) -> Result { + #[tool( + description = "Get time series data for a specific channel. Optionally filter by time range." + )] + async fn get_channel_data( + &self, + Parameters(req): Parameters, + ) -> Result { let time_range = match (req.start_time, req.end_time) { (Some(start), Some(end)) => Some((start, end)), _ => None, @@ -283,7 +409,10 @@ impl UltraLogMcpServer { } #[tool(description = "Get statistics (min, max, mean, std_dev, median) for a channel.")] - async fn get_channel_stats(&self, Parameters(req): Parameters) -> Result { + async fn get_channel_stats( + &self, + Parameters(req): Parameters, + ) -> Result { let time_range = match (req.start_time, req.end_time) { (Some(start), Some(end)) => Some((start, end)), _ => None, @@ -305,41 +434,63 @@ impl UltraLogMcpServer { } } - #[tool(description = "Add a channel to the chart display. The user will see this channel visualized in the UltraLog GUI.")] - async fn select_channel(&self, Parameters(req): Parameters) -> Result { + #[tool( + description = "Add a channel to the chart display. The user will see this channel visualized in the UltraLog GUI." + )] + async fn select_channel( + &self, + Parameters(req): Parameters, + ) -> Result { match self.send_command(IpcCommand::SelectChannel { file_id: req.file_id, channel_name: req.channel_name, }) { - Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text("Channel selected")])), + Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text( + "Channel selected", + )])), Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), Err(e) => Err(Self::mcp_error(e)), } } #[tool(description = "Remove a channel from the chart display.")] - async fn deselect_channel(&self, Parameters(req): Parameters) -> Result { + async fn deselect_channel( + &self, + Parameters(req): Parameters, + ) -> Result { match self.send_command(IpcCommand::DeselectChannel { file_id: req.file_id, channel_name: req.channel_name, }) { - Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text("Channel deselected")])), + Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text( + "Channel deselected", + )])), Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), Err(e) => Err(Self::mcp_error(e)), } } #[tool(description = "Remove all channels from the chart display.")] - async fn deselect_all_channels(&self, Parameters(_): Parameters) -> Result { + async fn deselect_all_channels( + &self, + Parameters(_): Parameters, + ) -> Result { match self.send_command(IpcCommand::DeselectAllChannels) { - Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text("All channels deselected")])), + Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text( + "All channels deselected", + )])), Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), Err(e) => Err(Self::mcp_error(e)), } } - #[tool(description = "Create a new computed channel from a mathematical formula. Supports: +, -, *, /, ^, sin, cos, tan, sqrt, abs, ln, log, min, max. Time-shifting: RPM[-1] (previous sample), RPM@-0.1s (100ms ago).")] - async fn create_computed_channel(&self, Parameters(req): Parameters) -> Result { + #[tool( + description = "Create a new computed channel from a mathematical formula. Supports: +, -, *, /, ^, sin, cos, tan, sqrt, abs, ln, log, min, max. Time-shifting: RPM[-1] (previous sample), RPM@-0.1s (100ms ago)." + )] + async fn create_computed_channel( + &self, + Parameters(req): Parameters, + ) -> Result { let name = req.name.clone(); match self.send_command(IpcCommand::CreateComputedChannel { name: req.name, @@ -347,25 +498,34 @@ impl UltraLogMcpServer { unit: req.unit, description: req.description, }) { - Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text( - format!("Computed channel '{}' created", name), - )])), + Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text(format!( + "Computed channel '{}' created", + name + ))])), Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), Err(e) => Err(Self::mcp_error(e)), } } #[tool(description = "Delete a computed channel.")] - async fn delete_computed_channel(&self, Parameters(req): Parameters) -> Result { + async fn delete_computed_channel( + &self, + Parameters(req): Parameters, + ) -> Result { match self.send_command(IpcCommand::DeleteComputedChannel { name: req.name }) { - Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text("Computed channel deleted")])), + Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text( + "Computed channel deleted", + )])), Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), Err(e) => Err(Self::mcp_error(e)), } } #[tool(description = "List all saved computed channel templates.")] - async fn list_computed_channels(&self, Parameters(_): Parameters) -> Result { + async fn list_computed_channels( + &self, + Parameters(_): Parameters, + ) -> Result { match self.send_command(IpcCommand::ListComputedChannels) { Ok(IpcResponse::Ok(Some(ResponseData::ComputedChannels(channels)))) => { Ok(CallToolResult::success(vec![Content::text( @@ -378,8 +538,13 @@ impl UltraLogMcpServer { } } - #[tool(description = "Evaluate a mathematical formula against the log data without creating a permanent channel. Returns the computed values and statistics.")] - async fn evaluate_formula(&self, Parameters(req): Parameters) -> Result { + #[tool( + description = "Evaluate a mathematical formula against the log data without creating a permanent channel. Returns the computed values and statistics." + )] + async fn evaluate_formula( + &self, + Parameters(req): Parameters, + ) -> Result { let time_range = match (req.start_time, req.end_time) { (Some(start), Some(end)) => Some((start, end)), _ => None, @@ -390,7 +555,11 @@ impl UltraLogMcpServer { formula: req.formula, time_range, }) { - Ok(IpcResponse::Ok(Some(ResponseData::FormulaResult { times, values, stats }))) => { + Ok(IpcResponse::Ok(Some(ResponseData::FormulaResult { + times, + values, + stats, + }))) => { let result = serde_json::json!({ "sample_count": times.len(), "stats": stats, @@ -407,54 +576,91 @@ impl UltraLogMcpServer { } } - #[tool(description = "Set the visible time range on the chart. Use this to zoom into a specific time window.")] - async fn set_time_range(&self, Parameters(req): Parameters) -> Result { - match self.send_command(IpcCommand::SetTimeRange { start: req.start, end: req.end }) { - Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text("Time range set")])), + #[tool( + description = "Set the visible time range on the chart. Use this to zoom into a specific time window." + )] + async fn set_time_range( + &self, + Parameters(req): Parameters, + ) -> Result { + match self.send_command(IpcCommand::SetTimeRange { + start: req.start, + end: req.end, + }) { + Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text( + "Time range set", + )])), Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), Err(e) => Err(Self::mcp_error(e)), } } - #[tool(description = "Set the cursor position on the timeline. The user will see channel values at this time.")] - async fn set_cursor(&self, Parameters(req): Parameters) -> Result { + #[tool( + description = "Set the cursor position on the timeline. The user will see channel values at this time." + )] + async fn set_cursor( + &self, + Parameters(req): Parameters, + ) -> Result { match self.send_command(IpcCommand::SetCursor { time: req.time }) { - Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text("Cursor set")])), + Ok(IpcResponse::Ok(_)) => { + Ok(CallToolResult::success(vec![Content::text("Cursor set")])) + } Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), Err(e) => Err(Self::mcp_error(e)), } } #[tool(description = "Start playback of the log data. The cursor will move through time.")] - async fn play(&self, Parameters(req): Parameters) -> Result { + async fn play( + &self, + Parameters(req): Parameters, + ) -> Result { match self.send_command(IpcCommand::Play { speed: req.speed }) { - Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text("Playback started")])), + Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text( + "Playback started", + )])), Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), Err(e) => Err(Self::mcp_error(e)), } } #[tool(description = "Pause playback.")] - async fn pause(&self, Parameters(_): Parameters) -> Result { + async fn pause( + &self, + Parameters(_): Parameters, + ) -> Result { match self.send_command(IpcCommand::Pause) { - Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text("Playback paused")])), + Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text( + "Playback paused", + )])), Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), Err(e) => Err(Self::mcp_error(e)), } } #[tool(description = "Stop playback and reset cursor to the start.")] - async fn stop(&self, Parameters(_): Parameters) -> Result { + async fn stop( + &self, + Parameters(_): Parameters, + ) -> Result { match self.send_command(IpcCommand::Stop) { - Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text("Playback stopped")])), + Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text( + "Playback stopped", + )])), Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), Err(e) => Err(Self::mcp_error(e)), } } #[tool(description = "Get channel values at the current cursor position.")] - async fn get_cursor_values(&self, Parameters(req): Parameters) -> Result { - match self.send_command(IpcCommand::GetCursorValues { file_id: req.file_id }) { + async fn get_cursor_values( + &self, + Parameters(req): Parameters, + ) -> Result { + match self.send_command(IpcCommand::GetCursorValues { + file_id: req.file_id, + }) { Ok(IpcResponse::Ok(Some(ResponseData::CursorValues(values)))) => { Ok(CallToolResult::success(vec![Content::text( serde_json::to_string_pretty(&values).unwrap_or_default(), @@ -466,8 +672,13 @@ impl UltraLogMcpServer { } } - #[tool(description = "Find peaks (local maxima) in a channel. Useful for finding acceleration events, boost spikes, etc.")] - async fn find_peaks(&self, Parameters(req): Parameters) -> Result { + #[tool( + description = "Find peaks (local maxima) in a channel. Useful for finding acceleration events, boost spikes, etc." + )] + async fn find_peaks( + &self, + Parameters(req): Parameters, + ) -> Result { match self.send_command(IpcCommand::FindPeaks { file_id: req.file_id, channel_name: req.channel_name, @@ -484,14 +695,22 @@ impl UltraLogMcpServer { } } - #[tool(description = "Calculate the correlation between two channels. Returns Pearson correlation coefficient and interpretation.")] - async fn correlate_channels(&self, Parameters(req): Parameters) -> Result { + #[tool( + description = "Calculate the correlation between two channels. Returns Pearson correlation coefficient and interpretation." + )] + async fn correlate_channels( + &self, + Parameters(req): Parameters, + ) -> Result { match self.send_command(IpcCommand::CorrelateChannels { file_id: req.file_id, channel_a: req.channel_a, channel_b: req.channel_b, }) { - Ok(IpcResponse::Ok(Some(ResponseData::Correlation { coefficient, interpretation }))) => { + Ok(IpcResponse::Ok(Some(ResponseData::Correlation { + coefficient, + interpretation, + }))) => { let result = serde_json::json!({ "coefficient": coefficient, "interpretation": interpretation @@ -506,23 +725,35 @@ impl UltraLogMcpServer { } } - #[tool(description = "Switch to scatter plot view to visualize correlation between two channels.")] - async fn show_scatter_plot(&self, Parameters(req): Parameters) -> Result { + #[tool( + description = "Switch to scatter plot view to visualize correlation between two channels." + )] + async fn show_scatter_plot( + &self, + Parameters(req): Parameters, + ) -> Result { match self.send_command(IpcCommand::ShowScatterPlot { file_id: req.file_id, x_channel: req.x_channel, y_channel: req.y_channel, }) { - Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text("Scatter plot displayed")])), + Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text( + "Scatter plot displayed", + )])), Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), Err(e) => Err(Self::mcp_error(e)), } } #[tool(description = "Switch back to time series chart view.")] - async fn show_chart(&self, Parameters(_): Parameters) -> Result { + async fn show_chart( + &self, + Parameters(_): Parameters, + ) -> Result { match self.send_command(IpcCommand::ShowChart) { - Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text("Chart view displayed")])), + Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text( + "Chart view displayed", + )])), Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), Err(e) => Err(Self::mcp_error(e)), } From 6d9f23d26cfb90525f32e144c20f7710877cc639 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 31 Dec 2025 05:09:39 +0000 Subject: [PATCH 3/6] Add dev branch to CI push triggers CI was only running on pushes to main/master but not dev. Added dev to push triggers to ensure CI runs on all primary branches. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79ce545..b9a924a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main, master] + branches: [main, master, dev] pull_request: branches: [main, master, dev] From 9e8ec26ea6b09fbdebbd1fb254a7211ec8746aa3 Mon Sep 17 00:00:00 2001 From: James Barney Date: Wed, 31 Dec 2025 11:54:30 -0500 Subject: [PATCH 4/6] mcp works but timesout a lot --- Cargo.lock | 13 +++ Cargo.toml | 2 +- src/main.rs | 10 ++- src/mcp/client.rs | 91 ++++++++------------ src/mcp/server.rs | 211 ++++++++++++++++++++++------------------------ 5 files changed, 156 insertions(+), 171 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 62cf1d3..2945141 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2170,6 +2170,15 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matchit" version = "0.8.4" @@ -4071,10 +4080,14 @@ version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ + "matchers", "nu-ansi-term", + "once_cell", + "regex-automata", "sharded-slab", "smallvec", "thread_local", + "tracing", "tracing-core", "tracing-log", ] diff --git a/Cargo.toml b/Cargo.toml index 1557689..e5a0f93 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,7 +70,7 @@ anyhow = "1.0" # Logging tracing = "0.1" -tracing-subscriber = "0.3" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } # MCP Server (Model Context Protocol) rmcp = { version = "0.12", features = ["server", "transport-streamable-http-server", "macros"] } diff --git a/src/main.rs b/src/main.rs index cb5bf10..3c06123 100644 --- a/src/main.rs +++ b/src/main.rs @@ -95,8 +95,14 @@ fn main() -> eframe::Result<()> { set_macos_app_name(); setup_linux_scaling(); - // Initialize logging - tracing_subscriber::fmt::init(); + // Initialize logging with RUST_LOG env filter support + // Default to showing info level for ultralog, can be overridden with RUST_LOG env var + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("ultralog=info")), + ) + .init(); // Track app startup for analytics ultralog::analytics::track_app_started(); diff --git a/src/mcp/client.rs b/src/mcp/client.rs index 3fd781a..f3645ac 100644 --- a/src/mcp/client.rs +++ b/src/mcp/client.rs @@ -2,15 +2,16 @@ use std::io::{BufRead, BufReader, Write}; use std::net::TcpStream; -use std::sync::Mutex; use std::time::Duration; use crate::ipc::commands::{IpcCommand, IpcResponse}; use crate::ipc::DEFAULT_IPC_PORT; /// Client for communicating with the UltraLog GUI +/// +/// Each command creates a new TCP connection to avoid stale connection issues. +/// The IPC server handles one command per connection. pub struct GuiClient { - stream: Mutex>, port: u16, } @@ -18,102 +19,76 @@ impl GuiClient { /// Create a new GUI client pub fn new() -> Self { Self { - stream: Mutex::new(None), port: DEFAULT_IPC_PORT, } } /// Create a new GUI client with a specific port pub fn with_port(port: u16) -> Self { - Self { - stream: Mutex::new(None), - port, - } + Self { port } } - /// Connect to the GUI if not already connected - fn ensure_connected(&self) -> Result<(), String> { - let mut stream = self - .stream - .lock() - .map_err(|e| format!("Lock error: {}", e))?; - - if stream.is_some() { - return Ok(()); - } - + /// Connect to the GUI + fn connect(&self) -> Result { let addr = format!("127.0.0.1:{}", self.port); - let new_stream = TcpStream::connect(&addr) + tracing::debug!("MCP client connecting to IPC server at {}", addr); + + // Use connect_timeout to avoid blocking indefinitely + let socket_addr: std::net::SocketAddr = addr + .parse() + .map_err(|e| format!("Invalid address: {}", e))?; + let stream = TcpStream::connect_timeout(&socket_addr, Duration::from_secs(5)) .map_err(|e| format!("Failed to connect to UltraLog GUI at {}: {}", addr, e))?; - new_stream + stream .set_read_timeout(Some(Duration::from_secs(30))) .map_err(|e| format!("Failed to set read timeout: {}", e))?; - new_stream + stream .set_write_timeout(Some(Duration::from_secs(10))) .map_err(|e| format!("Failed to set write timeout: {}", e))?; - *stream = Some(new_stream); - Ok(()) + tracing::debug!("MCP client connected to IPC server"); + Ok(stream) } /// Send a command to the GUI and get a response pub fn send_command(&self, command: IpcCommand) -> Result { - self.ensure_connected()?; - - let mut stream_guard = self - .stream - .lock() - .map_err(|e| format!("Lock error: {}", e))?; + tracing::debug!("MCP client sending command: {:?}", command); - // Take the stream out temporarily - let mut stream = stream_guard - .take() - .ok_or_else(|| "Not connected".to_string())?; + // Create a new connection for each command + let mut stream = self.connect()?; // Serialize and send the command let json = serde_json::to_string(&command) .map_err(|e| format!("Failed to serialize command: {}", e))?; - if let Err(e) = writeln!(stream, "{}", json) { - // Connection lost, don't put it back - return Err(format!("Failed to send command: {}", e)); - } + tracing::debug!("MCP client writing to IPC: {}", json); + writeln!(stream, "{}", json) + .map_err(|e| format!("Failed to send command: {}", e))?; + stream.flush() + .map_err(|e| format!("Failed to flush: {}", e))?; - if let Err(e) = stream.flush() { - return Err(format!("Failed to flush: {}", e)); - } + tracing::debug!("MCP client waiting for response..."); // Read the response - let mut reader = BufReader::new( - stream - .try_clone() - .map_err(|e| format!("Clone error: {}", e))?, - ); + let mut reader = BufReader::new(&stream); let mut response_line = String::new(); + reader.read_line(&mut response_line) + .map_err(|e| format!("Failed to read response: {}", e))?; - if let Err(e) = reader.read_line(&mut response_line) { - return Err(format!("Failed to read response: {}", e)); - } + tracing::debug!("MCP client received response: {}", response_line.trim()); - // Put the stream back - *stream_guard = Some(stream); + // Connection will be closed when stream is dropped // Parse the response - serde_json::from_str(&response_line).map_err(|e| format!("Failed to parse response: {}", e)) + serde_json::from_str(&response_line) + .map_err(|e| format!("Failed to parse response: {}", e)) } /// Check if the GUI is running and responsive pub fn ping(&self) -> bool { matches!(self.send_command(IpcCommand::Ping), Ok(IpcResponse::Ok(_))) } - - /// Disconnect from the GUI - pub fn disconnect(&self) { - if let Ok(mut stream) = self.stream.lock() { - *stream = None; - } - } } impl Default for GuiClient { diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 588a858..928acd5 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -69,7 +69,8 @@ pub fn start_mcp_server(mcp_port: u16, ipc_port: u16) -> Result Self { + tracing::info!("Creating new UltraLogMcpServer instance for IPC port {}", ipc_port); + let router = Self::tool_router(); + tracing::info!("Tool router created with {} tools", router.list_all().len()); Self { client: Arc::new(GuiClient::with_port(ipc_port)), - tool_router: Self::tool_router(), + tool_router: router, } } @@ -146,6 +150,15 @@ impl UltraLogMcpServer { self.client.send_command(command) } + /// Async wrapper for send_command that uses spawn_blocking to avoid blocking the async runtime + async fn send_command_async(&self, command: IpcCommand) -> Result { + let client = self.client.clone(); + tokio::task::spawn_blocking(move || client.send_command(command)) + .await + .map_err(|e| Self::mcp_error(format!("Task join error: {}", e)))? + .map_err(Self::mcp_error) + } + fn mcp_error(message: impl Into) -> McpError { McpError { code: ErrorCode(-32603), @@ -302,14 +315,13 @@ impl UltraLogMcpServer { &self, Parameters(_): Parameters, ) -> Result { - match self.send_command(IpcCommand::GetState) { - Ok(IpcResponse::Ok(Some(ResponseData::State(state)))) => { + match self.send_command_async(IpcCommand::GetState).await? { + IpcResponse::Ok(Some(ResponseData::State(state))) => { Ok(CallToolResult::success(vec![Content::text( serde_json::to_string_pretty(&state).unwrap_or_default(), )])) } - Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), - Err(e) => Err(Self::mcp_error(e)), + IpcResponse::Error { message } => Err(Self::mcp_error(message)), _ => Err(Self::mcp_error("Unexpected response")), } } @@ -321,19 +333,18 @@ impl UltraLogMcpServer { &self, Parameters(req): Parameters, ) -> Result { - match self.send_command(IpcCommand::LoadFile { path: req.path }) { - Ok(IpcResponse::Ok(Some(ResponseData::FileLoaded(info)))) => { + match self.send_command_async(IpcCommand::LoadFile { path: req.path }).await? { + IpcResponse::Ok(Some(ResponseData::FileLoaded(info))) => { Ok(CallToolResult::success(vec![Content::text( serde_json::to_string_pretty(&info).unwrap_or_default(), )])) } - Ok(IpcResponse::Ok(Some(ResponseData::Ack))) => { + IpcResponse::Ok(Some(ResponseData::Ack)) => { Ok(CallToolResult::success(vec![Content::text( "File is being loaded. Use get_state to check when ready.", )])) } - Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), - Err(e) => Err(Self::mcp_error(e)), + IpcResponse::Error { message } => Err(Self::mcp_error(message)), _ => Err(Self::mcp_error("Unexpected response")), } } @@ -343,14 +354,13 @@ impl UltraLogMcpServer { &self, Parameters(req): Parameters, ) -> Result { - match self.send_command(IpcCommand::CloseFile { + match self.send_command_async(IpcCommand::CloseFile { file_id: req.file_id, - }) { - Ok(IpcResponse::Ok(_)) => { + }).await? { + IpcResponse::Ok(_) => { Ok(CallToolResult::success(vec![Content::text("File closed")])) } - Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), - Err(e) => Err(Self::mcp_error(e)), + IpcResponse::Error { message } => Err(Self::mcp_error(message)), } } @@ -361,16 +371,15 @@ impl UltraLogMcpServer { &self, Parameters(req): Parameters, ) -> Result { - match self.send_command(IpcCommand::ListChannels { + match self.send_command_async(IpcCommand::ListChannels { file_id: req.file_id, - }) { - Ok(IpcResponse::Ok(Some(ResponseData::Channels(channels)))) => { + }).await? { + IpcResponse::Ok(Some(ResponseData::Channels(channels))) => { Ok(CallToolResult::success(vec![Content::text( serde_json::to_string_pretty(&channels).unwrap_or_default(), )])) } - Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), - Err(e) => Err(Self::mcp_error(e)), + IpcResponse::Error { message } => Err(Self::mcp_error(message)), _ => Err(Self::mcp_error("Unexpected response")), } } @@ -387,12 +396,12 @@ impl UltraLogMcpServer { _ => None, }; - match self.send_command(IpcCommand::GetChannelData { + match self.send_command_async(IpcCommand::GetChannelData { file_id: req.file_id, channel_name: req.channel_name, time_range, - }) { - Ok(IpcResponse::Ok(Some(ResponseData::ChannelData { times, values }))) => { + }).await? { + IpcResponse::Ok(Some(ResponseData::ChannelData { times, values })) => { let result = serde_json::json!({ "sample_count": times.len(), "times": times, @@ -402,8 +411,7 @@ impl UltraLogMcpServer { serde_json::to_string_pretty(&result).unwrap_or_default(), )])) } - Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), - Err(e) => Err(Self::mcp_error(e)), + IpcResponse::Error { message } => Err(Self::mcp_error(message)), _ => Err(Self::mcp_error("Unexpected response")), } } @@ -418,18 +426,17 @@ impl UltraLogMcpServer { _ => None, }; - match self.send_command(IpcCommand::GetChannelStats { + match self.send_command_async(IpcCommand::GetChannelStats { file_id: req.file_id, channel_name: req.channel_name, time_range, - }) { - Ok(IpcResponse::Ok(Some(ResponseData::Stats(stats)))) => { + }).await? { + IpcResponse::Ok(Some(ResponseData::Stats(stats))) => { Ok(CallToolResult::success(vec![Content::text( serde_json::to_string_pretty(&stats).unwrap_or_default(), )])) } - Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), - Err(e) => Err(Self::mcp_error(e)), + IpcResponse::Error { message } => Err(Self::mcp_error(message)), _ => Err(Self::mcp_error("Unexpected response")), } } @@ -441,15 +448,14 @@ impl UltraLogMcpServer { &self, Parameters(req): Parameters, ) -> Result { - match self.send_command(IpcCommand::SelectChannel { + match self.send_command_async(IpcCommand::SelectChannel { file_id: req.file_id, channel_name: req.channel_name, - }) { - Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text( + }).await? { + IpcResponse::Ok(_) => Ok(CallToolResult::success(vec![Content::text( "Channel selected", )])), - Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), - Err(e) => Err(Self::mcp_error(e)), + IpcResponse::Error { message } => Err(Self::mcp_error(message)), } } @@ -458,15 +464,14 @@ impl UltraLogMcpServer { &self, Parameters(req): Parameters, ) -> Result { - match self.send_command(IpcCommand::DeselectChannel { + match self.send_command_async(IpcCommand::DeselectChannel { file_id: req.file_id, channel_name: req.channel_name, - }) { - Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text( + }).await? { + IpcResponse::Ok(_) => Ok(CallToolResult::success(vec![Content::text( "Channel deselected", )])), - Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), - Err(e) => Err(Self::mcp_error(e)), + IpcResponse::Error { message } => Err(Self::mcp_error(message)), } } @@ -475,12 +480,11 @@ impl UltraLogMcpServer { &self, Parameters(_): Parameters, ) -> Result { - match self.send_command(IpcCommand::DeselectAllChannels) { - Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text( + match self.send_command_async(IpcCommand::DeselectAllChannels).await? { + IpcResponse::Ok(_) => Ok(CallToolResult::success(vec![Content::text( "All channels deselected", )])), - Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), - Err(e) => Err(Self::mcp_error(e)), + IpcResponse::Error { message } => Err(Self::mcp_error(message)), } } @@ -492,18 +496,17 @@ impl UltraLogMcpServer { Parameters(req): Parameters, ) -> Result { let name = req.name.clone(); - match self.send_command(IpcCommand::CreateComputedChannel { + match self.send_command_async(IpcCommand::CreateComputedChannel { name: req.name, formula: req.formula, unit: req.unit, description: req.description, - }) { - Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text(format!( + }).await? { + IpcResponse::Ok(_) => Ok(CallToolResult::success(vec![Content::text(format!( "Computed channel '{}' created", name ))])), - Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), - Err(e) => Err(Self::mcp_error(e)), + IpcResponse::Error { message } => Err(Self::mcp_error(message)), } } @@ -512,12 +515,11 @@ impl UltraLogMcpServer { &self, Parameters(req): Parameters, ) -> Result { - match self.send_command(IpcCommand::DeleteComputedChannel { name: req.name }) { - Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text( + match self.send_command_async(IpcCommand::DeleteComputedChannel { name: req.name }).await? { + IpcResponse::Ok(_) => Ok(CallToolResult::success(vec![Content::text( "Computed channel deleted", )])), - Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), - Err(e) => Err(Self::mcp_error(e)), + IpcResponse::Error { message } => Err(Self::mcp_error(message)), } } @@ -526,14 +528,13 @@ impl UltraLogMcpServer { &self, Parameters(_): Parameters, ) -> Result { - match self.send_command(IpcCommand::ListComputedChannels) { - Ok(IpcResponse::Ok(Some(ResponseData::ComputedChannels(channels)))) => { + match self.send_command_async(IpcCommand::ListComputedChannels).await? { + IpcResponse::Ok(Some(ResponseData::ComputedChannels(channels))) => { Ok(CallToolResult::success(vec![Content::text( serde_json::to_string_pretty(&channels).unwrap_or_default(), )])) } - Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), - Err(e) => Err(Self::mcp_error(e)), + IpcResponse::Error { message } => Err(Self::mcp_error(message)), _ => Err(Self::mcp_error("Unexpected response")), } } @@ -550,16 +551,16 @@ impl UltraLogMcpServer { _ => None, }; - match self.send_command(IpcCommand::EvaluateFormula { + match self.send_command_async(IpcCommand::EvaluateFormula { file_id: req.file_id, formula: req.formula, time_range, - }) { - Ok(IpcResponse::Ok(Some(ResponseData::FormulaResult { + }).await? { + IpcResponse::Ok(Some(ResponseData::FormulaResult { times, values, stats, - }))) => { + })) => { let result = serde_json::json!({ "sample_count": times.len(), "stats": stats, @@ -570,8 +571,7 @@ impl UltraLogMcpServer { serde_json::to_string_pretty(&result).unwrap_or_default(), )])) } - Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), - Err(e) => Err(Self::mcp_error(e)), + IpcResponse::Error { message } => Err(Self::mcp_error(message)), _ => Err(Self::mcp_error("Unexpected response")), } } @@ -583,15 +583,14 @@ impl UltraLogMcpServer { &self, Parameters(req): Parameters, ) -> Result { - match self.send_command(IpcCommand::SetTimeRange { + match self.send_command_async(IpcCommand::SetTimeRange { start: req.start, end: req.end, - }) { - Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text( + }).await? { + IpcResponse::Ok(_) => Ok(CallToolResult::success(vec![Content::text( "Time range set", )])), - Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), - Err(e) => Err(Self::mcp_error(e)), + IpcResponse::Error { message } => Err(Self::mcp_error(message)), } } @@ -602,12 +601,11 @@ impl UltraLogMcpServer { &self, Parameters(req): Parameters, ) -> Result { - match self.send_command(IpcCommand::SetCursor { time: req.time }) { - Ok(IpcResponse::Ok(_)) => { + match self.send_command_async(IpcCommand::SetCursor { time: req.time }).await? { + IpcResponse::Ok(_) => { Ok(CallToolResult::success(vec![Content::text("Cursor set")])) } - Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), - Err(e) => Err(Self::mcp_error(e)), + IpcResponse::Error { message } => Err(Self::mcp_error(message)), } } @@ -616,12 +614,11 @@ impl UltraLogMcpServer { &self, Parameters(req): Parameters, ) -> Result { - match self.send_command(IpcCommand::Play { speed: req.speed }) { - Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text( + match self.send_command_async(IpcCommand::Play { speed: req.speed }).await? { + IpcResponse::Ok(_) => Ok(CallToolResult::success(vec![Content::text( "Playback started", )])), - Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), - Err(e) => Err(Self::mcp_error(e)), + IpcResponse::Error { message } => Err(Self::mcp_error(message)), } } @@ -630,12 +627,11 @@ impl UltraLogMcpServer { &self, Parameters(_): Parameters, ) -> Result { - match self.send_command(IpcCommand::Pause) { - Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text( + match self.send_command_async(IpcCommand::Pause).await? { + IpcResponse::Ok(_) => Ok(CallToolResult::success(vec![Content::text( "Playback paused", )])), - Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), - Err(e) => Err(Self::mcp_error(e)), + IpcResponse::Error { message } => Err(Self::mcp_error(message)), } } @@ -644,12 +640,11 @@ impl UltraLogMcpServer { &self, Parameters(_): Parameters, ) -> Result { - match self.send_command(IpcCommand::Stop) { - Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text( + match self.send_command_async(IpcCommand::Stop).await? { + IpcResponse::Ok(_) => Ok(CallToolResult::success(vec![Content::text( "Playback stopped", )])), - Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), - Err(e) => Err(Self::mcp_error(e)), + IpcResponse::Error { message } => Err(Self::mcp_error(message)), } } @@ -658,16 +653,15 @@ impl UltraLogMcpServer { &self, Parameters(req): Parameters, ) -> Result { - match self.send_command(IpcCommand::GetCursorValues { + match self.send_command_async(IpcCommand::GetCursorValues { file_id: req.file_id, - }) { - Ok(IpcResponse::Ok(Some(ResponseData::CursorValues(values)))) => { + }).await? { + IpcResponse::Ok(Some(ResponseData::CursorValues(values))) => { Ok(CallToolResult::success(vec![Content::text( serde_json::to_string_pretty(&values).unwrap_or_default(), )])) } - Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), - Err(e) => Err(Self::mcp_error(e)), + IpcResponse::Error { message } => Err(Self::mcp_error(message)), _ => Err(Self::mcp_error("Unexpected response")), } } @@ -679,18 +673,17 @@ impl UltraLogMcpServer { &self, Parameters(req): Parameters, ) -> Result { - match self.send_command(IpcCommand::FindPeaks { + match self.send_command_async(IpcCommand::FindPeaks { file_id: req.file_id, channel_name: req.channel_name, min_prominence: req.min_prominence, - }) { - Ok(IpcResponse::Ok(Some(ResponseData::Peaks(peaks)))) => { + }).await? { + IpcResponse::Ok(Some(ResponseData::Peaks(peaks))) => { Ok(CallToolResult::success(vec![Content::text( serde_json::to_string_pretty(&peaks).unwrap_or_default(), )])) } - Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), - Err(e) => Err(Self::mcp_error(e)), + IpcResponse::Error { message } => Err(Self::mcp_error(message)), _ => Err(Self::mcp_error("Unexpected response")), } } @@ -702,15 +695,15 @@ impl UltraLogMcpServer { &self, Parameters(req): Parameters, ) -> Result { - match self.send_command(IpcCommand::CorrelateChannels { + match self.send_command_async(IpcCommand::CorrelateChannels { file_id: req.file_id, channel_a: req.channel_a, channel_b: req.channel_b, - }) { - Ok(IpcResponse::Ok(Some(ResponseData::Correlation { + }).await? { + IpcResponse::Ok(Some(ResponseData::Correlation { coefficient, interpretation, - }))) => { + })) => { let result = serde_json::json!({ "coefficient": coefficient, "interpretation": interpretation @@ -719,8 +712,7 @@ impl UltraLogMcpServer { serde_json::to_string_pretty(&result).unwrap_or_default(), )])) } - Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), - Err(e) => Err(Self::mcp_error(e)), + IpcResponse::Error { message } => Err(Self::mcp_error(message)), _ => Err(Self::mcp_error("Unexpected response")), } } @@ -732,16 +724,15 @@ impl UltraLogMcpServer { &self, Parameters(req): Parameters, ) -> Result { - match self.send_command(IpcCommand::ShowScatterPlot { + match self.send_command_async(IpcCommand::ShowScatterPlot { file_id: req.file_id, x_channel: req.x_channel, y_channel: req.y_channel, - }) { - Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text( + }).await? { + IpcResponse::Ok(_) => Ok(CallToolResult::success(vec![Content::text( "Scatter plot displayed", )])), - Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), - Err(e) => Err(Self::mcp_error(e)), + IpcResponse::Error { message } => Err(Self::mcp_error(message)), } } @@ -750,12 +741,11 @@ impl UltraLogMcpServer { &self, Parameters(_): Parameters, ) -> Result { - match self.send_command(IpcCommand::ShowChart) { - Ok(IpcResponse::Ok(_)) => Ok(CallToolResult::success(vec![Content::text( + match self.send_command_async(IpcCommand::ShowChart).await? { + IpcResponse::Ok(_) => Ok(CallToolResult::success(vec![Content::text( "Chart view displayed", )])), - Ok(IpcResponse::Error { message }) => Err(Self::mcp_error(message)), - Err(e) => Err(Self::mcp_error(e)), + IpcResponse::Error { message } => Err(Self::mcp_error(message)), } } } @@ -763,6 +753,7 @@ impl UltraLogMcpServer { #[tool_handler] impl ServerHandler for UltraLogMcpServer { fn get_info(&self) -> ServerInfo { + tracing::info!("get_info called - returning server capabilities"); ServerInfo { protocol_version: ProtocolVersion::V_2024_11_05, capabilities: ServerCapabilities::builder().enable_tools().build(), From 935888da8c12301f48532e536dbd70f473aa8e49 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Thu, 1 Jan 2026 15:07:49 -0500 Subject: [PATCH 5/6] Formats code Signed-off-by: Cole Gentry --- src/mcp/client.rs | 12 +-- src/mcp/server.rs | 196 +++++++++++++++++++++++++++++----------------- 2 files changed, 132 insertions(+), 76 deletions(-) diff --git a/src/mcp/client.rs b/src/mcp/client.rs index f3645ac..60d48bb 100644 --- a/src/mcp/client.rs +++ b/src/mcp/client.rs @@ -63,9 +63,9 @@ impl GuiClient { .map_err(|e| format!("Failed to serialize command: {}", e))?; tracing::debug!("MCP client writing to IPC: {}", json); - writeln!(stream, "{}", json) - .map_err(|e| format!("Failed to send command: {}", e))?; - stream.flush() + writeln!(stream, "{}", json).map_err(|e| format!("Failed to send command: {}", e))?; + stream + .flush() .map_err(|e| format!("Failed to flush: {}", e))?; tracing::debug!("MCP client waiting for response..."); @@ -73,7 +73,8 @@ impl GuiClient { // Read the response let mut reader = BufReader::new(&stream); let mut response_line = String::new(); - reader.read_line(&mut response_line) + reader + .read_line(&mut response_line) .map_err(|e| format!("Failed to read response: {}", e))?; tracing::debug!("MCP client received response: {}", response_line.trim()); @@ -81,8 +82,7 @@ impl GuiClient { // Connection will be closed when stream is dropped // Parse the response - serde_json::from_str(&response_line) - .map_err(|e| format!("Failed to parse response: {}", e)) + serde_json::from_str(&response_line).map_err(|e| format!("Failed to parse response: {}", e)) } /// Check if the GUI is running and responsive diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 928acd5..f598dde 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -137,7 +137,10 @@ impl UltraLogMcpServer { } pub fn with_ipc_port(ipc_port: u16) -> Self { - tracing::info!("Creating new UltraLogMcpServer instance for IPC port {}", ipc_port); + tracing::info!( + "Creating new UltraLogMcpServer instance for IPC port {}", + ipc_port + ); let router = Self::tool_router(); tracing::info!("Tool router created with {} tools", router.list_all().len()); Self { @@ -333,7 +336,10 @@ impl UltraLogMcpServer { &self, Parameters(req): Parameters, ) -> Result { - match self.send_command_async(IpcCommand::LoadFile { path: req.path }).await? { + match self + .send_command_async(IpcCommand::LoadFile { path: req.path }) + .await? + { IpcResponse::Ok(Some(ResponseData::FileLoaded(info))) => { Ok(CallToolResult::success(vec![Content::text( serde_json::to_string_pretty(&info).unwrap_or_default(), @@ -354,12 +360,13 @@ impl UltraLogMcpServer { &self, Parameters(req): Parameters, ) -> Result { - match self.send_command_async(IpcCommand::CloseFile { - file_id: req.file_id, - }).await? { - IpcResponse::Ok(_) => { - Ok(CallToolResult::success(vec![Content::text("File closed")])) - } + match self + .send_command_async(IpcCommand::CloseFile { + file_id: req.file_id, + }) + .await? + { + IpcResponse::Ok(_) => Ok(CallToolResult::success(vec![Content::text("File closed")])), IpcResponse::Error { message } => Err(Self::mcp_error(message)), } } @@ -371,9 +378,12 @@ impl UltraLogMcpServer { &self, Parameters(req): Parameters, ) -> Result { - match self.send_command_async(IpcCommand::ListChannels { - file_id: req.file_id, - }).await? { + match self + .send_command_async(IpcCommand::ListChannels { + file_id: req.file_id, + }) + .await? + { IpcResponse::Ok(Some(ResponseData::Channels(channels))) => { Ok(CallToolResult::success(vec![Content::text( serde_json::to_string_pretty(&channels).unwrap_or_default(), @@ -396,11 +406,14 @@ impl UltraLogMcpServer { _ => None, }; - match self.send_command_async(IpcCommand::GetChannelData { - file_id: req.file_id, - channel_name: req.channel_name, - time_range, - }).await? { + match self + .send_command_async(IpcCommand::GetChannelData { + file_id: req.file_id, + channel_name: req.channel_name, + time_range, + }) + .await? + { IpcResponse::Ok(Some(ResponseData::ChannelData { times, values })) => { let result = serde_json::json!({ "sample_count": times.len(), @@ -426,11 +439,14 @@ impl UltraLogMcpServer { _ => None, }; - match self.send_command_async(IpcCommand::GetChannelStats { - file_id: req.file_id, - channel_name: req.channel_name, - time_range, - }).await? { + match self + .send_command_async(IpcCommand::GetChannelStats { + file_id: req.file_id, + channel_name: req.channel_name, + time_range, + }) + .await? + { IpcResponse::Ok(Some(ResponseData::Stats(stats))) => { Ok(CallToolResult::success(vec![Content::text( serde_json::to_string_pretty(&stats).unwrap_or_default(), @@ -448,10 +464,13 @@ impl UltraLogMcpServer { &self, Parameters(req): Parameters, ) -> Result { - match self.send_command_async(IpcCommand::SelectChannel { - file_id: req.file_id, - channel_name: req.channel_name, - }).await? { + match self + .send_command_async(IpcCommand::SelectChannel { + file_id: req.file_id, + channel_name: req.channel_name, + }) + .await? + { IpcResponse::Ok(_) => Ok(CallToolResult::success(vec![Content::text( "Channel selected", )])), @@ -464,10 +483,13 @@ impl UltraLogMcpServer { &self, Parameters(req): Parameters, ) -> Result { - match self.send_command_async(IpcCommand::DeselectChannel { - file_id: req.file_id, - channel_name: req.channel_name, - }).await? { + match self + .send_command_async(IpcCommand::DeselectChannel { + file_id: req.file_id, + channel_name: req.channel_name, + }) + .await? + { IpcResponse::Ok(_) => Ok(CallToolResult::success(vec![Content::text( "Channel deselected", )])), @@ -480,7 +502,10 @@ impl UltraLogMcpServer { &self, Parameters(_): Parameters, ) -> Result { - match self.send_command_async(IpcCommand::DeselectAllChannels).await? { + match self + .send_command_async(IpcCommand::DeselectAllChannels) + .await? + { IpcResponse::Ok(_) => Ok(CallToolResult::success(vec![Content::text( "All channels deselected", )])), @@ -496,12 +521,15 @@ impl UltraLogMcpServer { Parameters(req): Parameters, ) -> Result { let name = req.name.clone(); - match self.send_command_async(IpcCommand::CreateComputedChannel { - name: req.name, - formula: req.formula, - unit: req.unit, - description: req.description, - }).await? { + match self + .send_command_async(IpcCommand::CreateComputedChannel { + name: req.name, + formula: req.formula, + unit: req.unit, + description: req.description, + }) + .await? + { IpcResponse::Ok(_) => Ok(CallToolResult::success(vec![Content::text(format!( "Computed channel '{}' created", name @@ -515,7 +543,10 @@ impl UltraLogMcpServer { &self, Parameters(req): Parameters, ) -> Result { - match self.send_command_async(IpcCommand::DeleteComputedChannel { name: req.name }).await? { + match self + .send_command_async(IpcCommand::DeleteComputedChannel { name: req.name }) + .await? + { IpcResponse::Ok(_) => Ok(CallToolResult::success(vec![Content::text( "Computed channel deleted", )])), @@ -528,7 +559,10 @@ impl UltraLogMcpServer { &self, Parameters(_): Parameters, ) -> Result { - match self.send_command_async(IpcCommand::ListComputedChannels).await? { + match self + .send_command_async(IpcCommand::ListComputedChannels) + .await? + { IpcResponse::Ok(Some(ResponseData::ComputedChannels(channels))) => { Ok(CallToolResult::success(vec![Content::text( serde_json::to_string_pretty(&channels).unwrap_or_default(), @@ -551,11 +585,14 @@ impl UltraLogMcpServer { _ => None, }; - match self.send_command_async(IpcCommand::EvaluateFormula { - file_id: req.file_id, - formula: req.formula, - time_range, - }).await? { + match self + .send_command_async(IpcCommand::EvaluateFormula { + file_id: req.file_id, + formula: req.formula, + time_range, + }) + .await? + { IpcResponse::Ok(Some(ResponseData::FormulaResult { times, values, @@ -583,10 +620,13 @@ impl UltraLogMcpServer { &self, Parameters(req): Parameters, ) -> Result { - match self.send_command_async(IpcCommand::SetTimeRange { - start: req.start, - end: req.end, - }).await? { + match self + .send_command_async(IpcCommand::SetTimeRange { + start: req.start, + end: req.end, + }) + .await? + { IpcResponse::Ok(_) => Ok(CallToolResult::success(vec![Content::text( "Time range set", )])), @@ -601,10 +641,11 @@ impl UltraLogMcpServer { &self, Parameters(req): Parameters, ) -> Result { - match self.send_command_async(IpcCommand::SetCursor { time: req.time }).await? { - IpcResponse::Ok(_) => { - Ok(CallToolResult::success(vec![Content::text("Cursor set")])) - } + match self + .send_command_async(IpcCommand::SetCursor { time: req.time }) + .await? + { + IpcResponse::Ok(_) => Ok(CallToolResult::success(vec![Content::text("Cursor set")])), IpcResponse::Error { message } => Err(Self::mcp_error(message)), } } @@ -614,7 +655,10 @@ impl UltraLogMcpServer { &self, Parameters(req): Parameters, ) -> Result { - match self.send_command_async(IpcCommand::Play { speed: req.speed }).await? { + match self + .send_command_async(IpcCommand::Play { speed: req.speed }) + .await? + { IpcResponse::Ok(_) => Ok(CallToolResult::success(vec![Content::text( "Playback started", )])), @@ -653,9 +697,12 @@ impl UltraLogMcpServer { &self, Parameters(req): Parameters, ) -> Result { - match self.send_command_async(IpcCommand::GetCursorValues { - file_id: req.file_id, - }).await? { + match self + .send_command_async(IpcCommand::GetCursorValues { + file_id: req.file_id, + }) + .await? + { IpcResponse::Ok(Some(ResponseData::CursorValues(values))) => { Ok(CallToolResult::success(vec![Content::text( serde_json::to_string_pretty(&values).unwrap_or_default(), @@ -673,11 +720,14 @@ impl UltraLogMcpServer { &self, Parameters(req): Parameters, ) -> Result { - match self.send_command_async(IpcCommand::FindPeaks { - file_id: req.file_id, - channel_name: req.channel_name, - min_prominence: req.min_prominence, - }).await? { + match self + .send_command_async(IpcCommand::FindPeaks { + file_id: req.file_id, + channel_name: req.channel_name, + min_prominence: req.min_prominence, + }) + .await? + { IpcResponse::Ok(Some(ResponseData::Peaks(peaks))) => { Ok(CallToolResult::success(vec![Content::text( serde_json::to_string_pretty(&peaks).unwrap_or_default(), @@ -695,11 +745,14 @@ impl UltraLogMcpServer { &self, Parameters(req): Parameters, ) -> Result { - match self.send_command_async(IpcCommand::CorrelateChannels { - file_id: req.file_id, - channel_a: req.channel_a, - channel_b: req.channel_b, - }).await? { + match self + .send_command_async(IpcCommand::CorrelateChannels { + file_id: req.file_id, + channel_a: req.channel_a, + channel_b: req.channel_b, + }) + .await? + { IpcResponse::Ok(Some(ResponseData::Correlation { coefficient, interpretation, @@ -724,11 +777,14 @@ impl UltraLogMcpServer { &self, Parameters(req): Parameters, ) -> Result { - match self.send_command_async(IpcCommand::ShowScatterPlot { - file_id: req.file_id, - x_channel: req.x_channel, - y_channel: req.y_channel, - }).await? { + match self + .send_command_async(IpcCommand::ShowScatterPlot { + file_id: req.file_id, + x_channel: req.x_channel, + y_channel: req.y_channel, + }) + .await? + { IpcResponse::Ok(_) => Ok(CallToolResult::success(vec![Content::text( "Scatter plot displayed", )])), From 17024fb8726f45b5973720235a6cf2e0bcfa6316 Mon Sep 17 00:00:00 2001 From: Cole Gentry Date: Thu, 1 Jan 2026 15:20:17 -0500 Subject: [PATCH 6/6] Fixes tests and fixes MCP timeouts Signed-off-by: Cole Gentry --- src/app.rs | 7 + src/ipc/commands.rs | 353 ++++++++++++++++++++++++++++++++++++++++++++ src/ipc/server.rs | 174 ++++++++++++++++++++++ src/mcp/server.rs | 4 - 4 files changed, 534 insertions(+), 4 deletions(-) diff --git a/src/app.rs b/src/app.rs index 864389b..1e91ee2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1466,6 +1466,13 @@ impl eframe::App for UltraLogApp { ctx.request_repaint(); } + // When MCP server is active, request repaint at 10Hz to poll for IPC commands + // This is much more CPU-efficient than continuous repaint while still being + // responsive enough for MCP commands (100ms latency max). + if self.ipc_server.is_some() { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } + // Toast notifications self.render_toast(ctx); diff --git a/src/ipc/commands.rs b/src/ipc/commands.rs index 5834cb9..f83128c 100644 --- a/src/ipc/commands.rs +++ b/src/ipc/commands.rs @@ -305,3 +305,356 @@ impl IpcResponse { } } } + +#[cfg(test)] +mod tests { + use super::*; + + // ======================================================================== + // IPC Command Serialization Tests + // ======================================================================== + + #[test] + fn test_ping_command_roundtrip() { + let cmd = IpcCommand::Ping; + let json = serde_json::to_string(&cmd).unwrap(); + let parsed: IpcCommand = serde_json::from_str(&json).unwrap(); + assert!(matches!(parsed, IpcCommand::Ping)); + } + + #[test] + fn test_get_state_command_roundtrip() { + let cmd = IpcCommand::GetState; + let json = serde_json::to_string(&cmd).unwrap(); + let parsed: IpcCommand = serde_json::from_str(&json).unwrap(); + assert!(matches!(parsed, IpcCommand::GetState)); + } + + #[test] + fn test_load_file_command_roundtrip() { + let cmd = IpcCommand::LoadFile { + path: "/path/to/file.csv".to_string(), + }; + let json = serde_json::to_string(&cmd).unwrap(); + let parsed: IpcCommand = serde_json::from_str(&json).unwrap(); + if let IpcCommand::LoadFile { path } = parsed { + assert_eq!(path, "/path/to/file.csv"); + } else { + panic!("Expected LoadFile command"); + } + } + + #[test] + fn test_get_channel_data_with_time_range() { + let cmd = IpcCommand::GetChannelData { + file_id: "0".to_string(), + channel_name: "RPM".to_string(), + time_range: Some((10.0, 20.0)), + }; + let json = serde_json::to_string(&cmd).unwrap(); + let parsed: IpcCommand = serde_json::from_str(&json).unwrap(); + if let IpcCommand::GetChannelData { + file_id, + channel_name, + time_range, + } = parsed + { + assert_eq!(file_id, "0"); + assert_eq!(channel_name, "RPM"); + assert_eq!(time_range, Some((10.0, 20.0))); + } else { + panic!("Expected GetChannelData command"); + } + } + + #[test] + fn test_get_channel_data_without_time_range() { + let cmd = IpcCommand::GetChannelData { + file_id: "0".to_string(), + channel_name: "Boost".to_string(), + time_range: None, + }; + let json = serde_json::to_string(&cmd).unwrap(); + let parsed: IpcCommand = serde_json::from_str(&json).unwrap(); + if let IpcCommand::GetChannelData { + file_id, + channel_name, + time_range, + } = parsed + { + assert_eq!(file_id, "0"); + assert_eq!(channel_name, "Boost"); + assert!(time_range.is_none()); + } else { + panic!("Expected GetChannelData command"); + } + } + + #[test] + fn test_create_computed_channel_command() { + let cmd = IpcCommand::CreateComputedChannel { + name: "Boost PSI".to_string(), + formula: "Manifold_Pressure_kPa / 6.895".to_string(), + unit: "PSI".to_string(), + description: Some("Boost in PSI".to_string()), + }; + let json = serde_json::to_string(&cmd).unwrap(); + let parsed: IpcCommand = serde_json::from_str(&json).unwrap(); + if let IpcCommand::CreateComputedChannel { + name, + formula, + unit, + description, + } = parsed + { + assert_eq!(name, "Boost PSI"); + assert_eq!(formula, "Manifold_Pressure_kPa / 6.895"); + assert_eq!(unit, "PSI"); + assert_eq!(description, Some("Boost in PSI".to_string())); + } else { + panic!("Expected CreateComputedChannel command"); + } + } + + #[test] + fn test_play_command_with_speed() { + let cmd = IpcCommand::Play { speed: Some(2.0) }; + let json = serde_json::to_string(&cmd).unwrap(); + let parsed: IpcCommand = serde_json::from_str(&json).unwrap(); + if let IpcCommand::Play { speed } = parsed { + assert_eq!(speed, Some(2.0)); + } else { + panic!("Expected Play command"); + } + } + + #[test] + fn test_find_peaks_command() { + let cmd = IpcCommand::FindPeaks { + file_id: "0".to_string(), + channel_name: "RPM".to_string(), + min_prominence: Some(100.0), + }; + let json = serde_json::to_string(&cmd).unwrap(); + let parsed: IpcCommand = serde_json::from_str(&json).unwrap(); + if let IpcCommand::FindPeaks { + file_id, + channel_name, + min_prominence, + } = parsed + { + assert_eq!(file_id, "0"); + assert_eq!(channel_name, "RPM"); + assert_eq!(min_prominence, Some(100.0)); + } else { + panic!("Expected FindPeaks command"); + } + } + + // ======================================================================== + // IPC Response Serialization Tests + // ======================================================================== + + #[test] + fn test_ok_response_roundtrip() { + let resp = IpcResponse::ok(); + let json = serde_json::to_string(&resp).unwrap(); + let parsed: IpcResponse = serde_json::from_str(&json).unwrap(); + assert!(matches!(parsed, IpcResponse::Ok(Some(ResponseData::Ack)))); + } + + #[test] + fn test_error_response_roundtrip() { + let resp = IpcResponse::error("Something went wrong"); + let json = serde_json::to_string(&resp).unwrap(); + let parsed: IpcResponse = serde_json::from_str(&json).unwrap(); + if let IpcResponse::Error { message } = parsed { + assert_eq!(message, "Something went wrong"); + } else { + panic!("Expected Error response"); + } + } + + #[test] + fn test_pong_response_roundtrip() { + let resp = IpcResponse::ok_with_data(ResponseData::Pong); + let json = serde_json::to_string(&resp).unwrap(); + let parsed: IpcResponse = serde_json::from_str(&json).unwrap(); + assert!(matches!(parsed, IpcResponse::Ok(Some(ResponseData::Pong)))); + } + + #[test] + fn test_channel_data_response_roundtrip() { + let resp = IpcResponse::ok_with_data(ResponseData::ChannelData { + times: vec![0.0, 0.1, 0.2, 0.3], + values: vec![1000.0, 1500.0, 2000.0, 2500.0], + }); + let json = serde_json::to_string(&resp).unwrap(); + let parsed: IpcResponse = serde_json::from_str(&json).unwrap(); + if let IpcResponse::Ok(Some(ResponseData::ChannelData { times, values })) = parsed { + assert_eq!(times, vec![0.0, 0.1, 0.2, 0.3]); + assert_eq!(values, vec![1000.0, 1500.0, 2000.0, 2500.0]); + } else { + panic!("Expected ChannelData response"); + } + } + + #[test] + fn test_stats_response_roundtrip() { + let stats = ChannelStats { + min: 800.0, + max: 7500.0, + mean: 3500.0, + std_dev: 1200.0, + median: 3200.0, + count: 1000, + min_time: 5.2, + max_time: 42.8, + }; + let resp = IpcResponse::ok_with_data(ResponseData::Stats(stats)); + let json = serde_json::to_string(&resp).unwrap(); + let parsed: IpcResponse = serde_json::from_str(&json).unwrap(); + if let IpcResponse::Ok(Some(ResponseData::Stats(s))) = parsed { + assert_eq!(s.min, 800.0); + assert_eq!(s.max, 7500.0); + assert_eq!(s.mean, 3500.0); + assert_eq!(s.count, 1000); + } else { + panic!("Expected Stats response"); + } + } + + #[test] + fn test_app_state_response_roundtrip() { + let state = AppState { + files: vec![FileInfo { + id: "0".to_string(), + path: "/path/to/log.csv".to_string(), + name: "log.csv".to_string(), + ecu_type: "Haltech".to_string(), + channel_count: 50, + record_count: 10000, + duration: 120.5, + sample_rate: 100.0, + }], + active_file: Some("0".to_string()), + selected_channels: vec![SelectedChannelInfo { + file_id: "0".to_string(), + channel_name: "RPM".to_string(), + color: "#FF0000".to_string(), + }], + cursor_time: Some(15.5), + visible_time_range: Some((10.0, 30.0)), + is_playing: false, + view_mode: "chart".to_string(), + }; + let resp = IpcResponse::ok_with_data(ResponseData::State(state)); + let json = serde_json::to_string(&resp).unwrap(); + let parsed: IpcResponse = serde_json::from_str(&json).unwrap(); + if let IpcResponse::Ok(Some(ResponseData::State(s))) = parsed { + assert_eq!(s.files.len(), 1); + assert_eq!(s.files[0].name, "log.csv"); + assert_eq!(s.selected_channels.len(), 1); + assert_eq!(s.cursor_time, Some(15.5)); + assert!(!s.is_playing); + } else { + panic!("Expected State response"); + } + } + + #[test] + fn test_correlation_response_roundtrip() { + let resp = IpcResponse::ok_with_data(ResponseData::Correlation { + coefficient: 0.87, + interpretation: "Strong positive correlation".to_string(), + }); + let json = serde_json::to_string(&resp).unwrap(); + let parsed: IpcResponse = serde_json::from_str(&json).unwrap(); + if let IpcResponse::Ok(Some(ResponseData::Correlation { + coefficient, + interpretation, + })) = parsed + { + assert!((coefficient - 0.87).abs() < 0.001); + assert_eq!(interpretation, "Strong positive correlation"); + } else { + panic!("Expected Correlation response"); + } + } + + #[test] + fn test_peaks_response_roundtrip() { + let peaks = vec![ + Peak { + time: 10.5, + value: 7200.0, + prominence: 500.0, + }, + Peak { + time: 25.3, + value: 7500.0, + prominence: 800.0, + }, + ]; + let resp = IpcResponse::ok_with_data(ResponseData::Peaks(peaks)); + let json = serde_json::to_string(&resp).unwrap(); + let parsed: IpcResponse = serde_json::from_str(&json).unwrap(); + if let IpcResponse::Ok(Some(ResponseData::Peaks(p))) = parsed { + assert_eq!(p.len(), 2); + assert_eq!(p[0].time, 10.5); + assert_eq!(p[1].value, 7500.0); + } else { + panic!("Expected Peaks response"); + } + } + + // ======================================================================== + // JSON Format Compatibility Tests + // ======================================================================== + + #[test] + fn test_command_json_format_is_stable() { + // Ensure the JSON format is what MCP clients expect + let cmd = IpcCommand::LoadFile { + path: "/test.csv".to_string(), + }; + let json = serde_json::to_string(&cmd).unwrap(); + // Should use tagged enum format + assert!(json.contains("\"type\":\"LoadFile\"")); + assert!(json.contains("\"payload\"")); + assert!(json.contains("\"/test.csv\"")); + } + + #[test] + fn test_response_json_format_is_stable() { + // Ensure the JSON format is what MCP clients expect + let resp = IpcResponse::ok(); + let json = serde_json::to_string(&resp).unwrap(); + // Should use tagged enum format + assert!(json.contains("\"status\":\"Ok\"")); + + let err = IpcResponse::error("test error"); + let json = serde_json::to_string(&err).unwrap(); + assert!(json.contains("\"status\":\"Error\"")); + assert!(json.contains("\"test error\"")); + } + + #[test] + fn test_command_can_be_parsed_from_external_json() { + // Test parsing JSON that might come from an external MCP client + let json = r#"{"type":"GetChannelData","payload":{"file_id":"0","channel_name":"RPM","time_range":[0.0,10.0]}}"#; + let cmd: IpcCommand = serde_json::from_str(json).unwrap(); + if let IpcCommand::GetChannelData { + file_id, + channel_name, + time_range, + } = cmd + { + assert_eq!(file_id, "0"); + assert_eq!(channel_name, "RPM"); + assert_eq!(time_range, Some((0.0, 10.0))); + } else { + panic!("Expected GetChannelData command"); + } + } +} diff --git a/src/ipc/server.rs b/src/ipc/server.rs index a87b37c..d782c39 100644 --- a/src/ipc/server.rs +++ b/src/ipc/server.rs @@ -165,3 +165,177 @@ impl IpcServer { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::ipc::commands::ResponseData; + use std::io::{BufRead, BufReader, Write}; + use std::net::TcpStream; + use std::time::Duration; + + /// Find an available port for testing + fn find_available_port() -> u16 { + // Bind to port 0 to get an available port from the OS + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.local_addr().unwrap().port() + } + + #[test] + fn test_ipc_server_starts_and_accepts_connections() { + let port = find_available_port(); + let server = IpcServer::start_on_port(port).expect("Failed to start server"); + assert!(server.is_running()); + assert_eq!(server.port(), port); + + // Try to connect + let stream = TcpStream::connect_timeout( + &format!("127.0.0.1:{}", port).parse().unwrap(), + Duration::from_secs(2), + ); + assert!(stream.is_ok(), "Should be able to connect to server"); + } + + #[test] + fn test_ipc_server_receives_command_via_channel() { + let port = find_available_port(); + let server = IpcServer::start_on_port(port).expect("Failed to start server"); + + // Connect and send a command + let mut stream = TcpStream::connect_timeout( + &format!("127.0.0.1:{}", port).parse().unwrap(), + Duration::from_secs(2), + ) + .expect("Failed to connect"); + stream.set_read_timeout(Some(Duration::from_secs(5))).ok(); + stream.set_write_timeout(Some(Duration::from_secs(5))).ok(); + + // Send a Ping command + let cmd = IpcCommand::Ping; + let json = serde_json::to_string(&cmd).unwrap(); + writeln!(stream, "{}", json).expect("Failed to write"); + stream.flush().expect("Failed to flush"); + + // Poll for the command (give the server thread time to process) + let mut received = None; + for _ in 0..50 { + if let Some(cmd) = server.poll_command() { + received = Some(cmd); + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + + assert!(received.is_some(), "Should receive command via channel"); + let (command, response_tx) = received.unwrap(); + assert!(matches!(command, IpcCommand::Ping)); + + // Send response back + response_tx + .send(IpcResponse::ok_with_data(ResponseData::Pong)) + .expect("Failed to send response"); + + // Read response from stream + let mut reader = BufReader::new(&stream); + let mut response_line = String::new(); + reader + .read_line(&mut response_line) + .expect("Failed to read"); + + let response: IpcResponse = serde_json::from_str(&response_line).expect("Failed to parse"); + assert!(matches!( + response, + IpcResponse::Ok(Some(ResponseData::Pong)) + )); + } + + #[test] + fn test_ipc_server_handles_sequential_connections() { + // This test mirrors the real usage: one command per connection + // (as documented in client.rs: "Each command creates a new TCP connection") + let port = find_available_port(); + let server = IpcServer::start_on_port(port).expect("Failed to start server"); + + let commands = vec![IpcCommand::Ping, IpcCommand::GetState, IpcCommand::Ping]; + + for cmd in commands { + // New connection for each command (matches real MCP client behavior) + let mut stream = TcpStream::connect_timeout( + &format!("127.0.0.1:{}", port).parse().unwrap(), + Duration::from_secs(2), + ) + .expect("Failed to connect"); + stream.set_read_timeout(Some(Duration::from_secs(5))).ok(); + stream.set_write_timeout(Some(Duration::from_secs(5))).ok(); + + let json = serde_json::to_string(&cmd).unwrap(); + writeln!(stream, "{}", json).expect("Failed to write"); + stream.flush().expect("Failed to flush"); + + // Poll for command + let mut received = None; + for _ in 0..50 { + if let Some(c) = server.poll_command() { + received = Some(c); + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + + assert!(received.is_some(), "Should receive command"); + let (_command, response_tx) = received.unwrap(); + + // Send response + response_tx + .send(IpcResponse::ok()) + .expect("Failed to send response"); + + // Read response + let mut reader = BufReader::new(&stream); + let mut response_line = String::new(); + reader + .read_line(&mut response_line) + .expect("Failed to read"); + let response: IpcResponse = + serde_json::from_str(&response_line).expect("Failed to parse"); + assert!(matches!(response, IpcResponse::Ok(_))); + } + } + + #[test] + fn test_ipc_server_handles_invalid_json() { + let port = find_available_port(); + let _server = IpcServer::start_on_port(port).expect("Failed to start server"); + + let mut stream = TcpStream::connect_timeout( + &format!("127.0.0.1:{}", port).parse().unwrap(), + Duration::from_secs(2), + ) + .expect("Failed to connect"); + stream.set_read_timeout(Some(Duration::from_secs(5))).ok(); + stream.set_write_timeout(Some(Duration::from_secs(5))).ok(); + + // Send invalid JSON + writeln!(stream, "{{not valid json}}").expect("Failed to write"); + stream.flush().expect("Failed to flush"); + + // Read error response + let mut reader = BufReader::new(&stream); + let mut response_line = String::new(); + reader + .read_line(&mut response_line) + .expect("Failed to read"); + + let response: IpcResponse = serde_json::from_str(&response_line).expect("Failed to parse"); + assert!(matches!(response, IpcResponse::Error { .. })); + } + + #[test] + fn test_ipc_server_poll_returns_none_when_empty() { + let port = find_available_port(); + let server = IpcServer::start_on_port(port).expect("Failed to start server"); + + // Poll without any connections - should return None immediately + assert!(server.poll_command().is_none()); + } +} diff --git a/src/mcp/server.rs b/src/mcp/server.rs index f598dde..8dfd69d 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -149,10 +149,6 @@ impl UltraLogMcpServer { } } - fn send_command(&self, command: IpcCommand) -> Result { - self.client.send_command(command) - } - /// Async wrapper for send_command that uses spawn_blocking to avoid blocking the async runtime async fn send_command_async(&self, command: IpcCommand) -> Result { let client = self.client.clone();