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] diff --git a/Cargo.lock b/Cargo.lock index d47193a..2945141 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" @@ -329,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" @@ -569,6 +630,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 +856,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 +1007,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 +1429,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 +1451,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1329,6 +1460,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 +1501,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 +1519,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", @@ -1588,12 +1738,102 @@ 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" +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 +1915,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" @@ -1924,6 +2170,21 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "md5" version = "0.7.0" @@ -1992,6 +2253,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 +2764,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 +3149,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 +3242,50 @@ 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", + "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]] +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" @@ -3036,6 +3378,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" @@ -3045,6 +3393,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 +3467,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" @@ -3106,6 +3491,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" @@ -3126,6 +3522,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" @@ -3267,6 +3675,29 @@ 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 = "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" @@ -3279,6 +3710,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" @@ -3317,6 +3754,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" @@ -3456,6 +3899,58 @@ 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-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" +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" @@ -3507,6 +4002,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" @@ -3557,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", ] @@ -3608,6 +4135,7 @@ name = "ultralog" version = "2.0.0" dependencies = [ "anyhow", + "axum", "dirs", "eframe", "egui_extras", @@ -3623,12 +4151,15 @@ dependencies = [ "rayon", "regex", "rfd", + "rmcp", + "schemars", "semver", "serde", "serde_json", "strum", "tar", "thiserror 2.0.17", + "tokio", "tracing", "tracing-subscriber", "ureq", @@ -4149,12 +4680,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..e5a0f93 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,7 +70,13 @@ 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"] } +tokio = { version = "1", features = ["full"] } +axum = "0.8" +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..1e91ee2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -14,6 +14,8 @@ use std::thread; 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, }; @@ -139,6 +141,11 @@ 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, + /// MCP HTTP server handle (embedded server for Claude Desktop connection) + mcp_server: Option, } impl Default for UltraLogApp { @@ -192,6 +199,8 @@ impl Default for UltraLogApp { analysis_results: HashMap::new(), show_analysis_panel: false, analysis_selected_category: None, + ipc_server: None, + mcp_server: None, } } } @@ -231,7 +240,38 @@ impl UltraLogApp { // Apply fonts cc.egui_ctx.set_fonts(fonts); - Self::default() + // 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) => { + ipc_port = server.port(); + tracing::info!("MCP IPC server started on port {}", ipc_port); + app.ipc_server = Some(server); + } + Err(e) => { + tracing::warn!("Failed to start MCP IPC server: {}", e); + } + } + + // 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 } // ======================================================================== @@ -1351,6 +1391,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 +1450,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()); @@ -1396,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 new file mode 100644 index 0000000..f83128c --- /dev/null +++ b/src/ipc/commands.rs @@ -0,0 +1,660 @@ +//! 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(), + } + } +} + +#[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/handler.rs b/src/ipc/handler.rs new file mode 100644 index 0000000..8663752 --- /dev/null +++ b/src/ipc/handler.rs @@ -0,0 +1,897 @@ +//! 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().is_multiple_of(2) { + (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..c1d8053 --- /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::{ChannelInfo, ChannelStats, FileInfo, IpcCommand, IpcResponse}; +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..d782c39 --- /dev/null +++ b/src/ipc/server.rs @@ -0,0 +1,341 @@ +//! 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(()) + } +} + +#[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/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/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 new file mode 100644 index 0000000..60d48bb --- /dev/null +++ b/src/mcp/client.rs @@ -0,0 +1,98 @@ +//! TCP client for communicating with the UltraLog GUI's IPC server + +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpStream; +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 { + port: u16, +} + +impl GuiClient { + /// Create a new GUI client + pub fn new() -> Self { + Self { + port: DEFAULT_IPC_PORT, + } + } + + /// Create a new GUI client with a specific port + pub fn with_port(port: u16) -> Self { + Self { port } + } + + /// Connect to the GUI + fn connect(&self) -> Result { + let addr = format!("127.0.0.1:{}", self.port); + 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))?; + + stream + .set_read_timeout(Some(Duration::from_secs(30))) + .map_err(|e| format!("Failed to set read timeout: {}", e))?; + stream + .set_write_timeout(Some(Duration::from_secs(10))) + .map_err(|e| format!("Failed to set write timeout: {}", e))?; + + 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 { + tracing::debug!("MCP client sending command: {:?}", command); + + // 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))?; + + 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))?; + + tracing::debug!("MCP client waiting for response..."); + + // Read the response + 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))?; + + tracing::debug!("MCP client received response: {}", response_line.trim()); + + // 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)) + } + + /// Check if the GUI is running and responsive + pub fn ping(&self) -> bool { + matches!(self.send_command(IpcCommand::Ping), Ok(IpcResponse::Ok(_))) + } +} + +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..1813f37 --- /dev/null +++ b/src/mcp/mod.rs @@ -0,0 +1,13 @@ +//! 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. +//! +//! 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::{start_mcp_server, McpServerHandle, UltraLogMcpServer, DEFAULT_MCP_PORT}; diff --git a/src/mcp/server.rs b/src/mcp/server.rs new file mode 100644 index 0000000..8dfd69d --- /dev/null +++ b/src/mcp/server.rs @@ -0,0 +1,828 @@ +//! MCP Server implementation for UltraLog +//! +//! 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::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_multi_thread() + .worker_threads(2) + .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)] +pub struct UltraLogMcpServer { + client: Arc, + tool_router: ToolRouter, +} + +impl UltraLogMcpServer { + pub fn new() -> Self { + Self::with_ipc_port(DEFAULT_IPC_PORT) + } + + pub fn with_ipc_port(ipc_port: u16) -> 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: router, + } + } + + /// 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), + 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_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(), + )])) + } + IpcResponse::Error { message } => Err(Self::mcp_error(message)), + _ => 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_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(), + )])) + } + IpcResponse::Ok(Some(ResponseData::Ack)) => { + Ok(CallToolResult::success(vec![Content::text( + "File is being loaded. Use get_state to check when ready.", + )])) + } + IpcResponse::Error { message } => Err(Self::mcp_error(message)), + _ => 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_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)), + } + } + + #[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_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(), + )])) + } + IpcResponse::Error { message } => Err(Self::mcp_error(message)), + _ => 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_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(), + "times": times, + "values": values + }); + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&result).unwrap_or_default(), + )])) + } + IpcResponse::Error { message } => Err(Self::mcp_error(message)), + _ => 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_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(), + )])) + } + IpcResponse::Error { message } => Err(Self::mcp_error(message)), + _ => 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_async(IpcCommand::SelectChannel { + file_id: req.file_id, + channel_name: req.channel_name, + }) + .await? + { + IpcResponse::Ok(_) => Ok(CallToolResult::success(vec![Content::text( + "Channel selected", + )])), + IpcResponse::Error { message } => Err(Self::mcp_error(message)), + } + } + + #[tool(description = "Remove a channel from the chart display.")] + async fn deselect_channel( + &self, + Parameters(req): Parameters, + ) -> Result { + 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", + )])), + IpcResponse::Error { message } => Err(Self::mcp_error(message)), + } + } + + #[tool(description = "Remove all channels from the chart display.")] + async fn deselect_all_channels( + &self, + Parameters(_): Parameters, + ) -> Result { + match self + .send_command_async(IpcCommand::DeselectAllChannels) + .await? + { + IpcResponse::Ok(_) => Ok(CallToolResult::success(vec![Content::text( + "All channels deselected", + )])), + IpcResponse::Error { message } => Err(Self::mcp_error(message)), + } + } + + #[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_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 + ))])), + IpcResponse::Error { message } => Err(Self::mcp_error(message)), + } + } + + #[tool(description = "Delete a computed channel.")] + async fn delete_computed_channel( + &self, + Parameters(req): Parameters, + ) -> Result { + match self + .send_command_async(IpcCommand::DeleteComputedChannel { name: req.name }) + .await? + { + IpcResponse::Ok(_) => Ok(CallToolResult::success(vec![Content::text( + "Computed channel deleted", + )])), + IpcResponse::Error { message } => Err(Self::mcp_error(message)), + } + } + + #[tool(description = "List all saved computed channel templates.")] + async fn list_computed_channels( + &self, + Parameters(_): Parameters, + ) -> Result { + 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(), + )])) + } + IpcResponse::Error { message } => Err(Self::mcp_error(message)), + _ => 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_async(IpcCommand::EvaluateFormula { + file_id: req.file_id, + formula: req.formula, + time_range, + }) + .await? + { + 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(), + )])) + } + IpcResponse::Error { message } => Err(Self::mcp_error(message)), + _ => 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_async(IpcCommand::SetTimeRange { + start: req.start, + end: req.end, + }) + .await? + { + IpcResponse::Ok(_) => Ok(CallToolResult::success(vec![Content::text( + "Time range set", + )])), + IpcResponse::Error { message } => Err(Self::mcp_error(message)), + } + } + + #[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_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)), + } + } + + #[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_async(IpcCommand::Play { speed: req.speed }) + .await? + { + IpcResponse::Ok(_) => Ok(CallToolResult::success(vec![Content::text( + "Playback started", + )])), + IpcResponse::Error { message } => Err(Self::mcp_error(message)), + } + } + + #[tool(description = "Pause playback.")] + async fn pause( + &self, + Parameters(_): Parameters, + ) -> Result { + match self.send_command_async(IpcCommand::Pause).await? { + IpcResponse::Ok(_) => Ok(CallToolResult::success(vec![Content::text( + "Playback paused", + )])), + IpcResponse::Error { message } => Err(Self::mcp_error(message)), + } + } + + #[tool(description = "Stop playback and reset cursor to the start.")] + async fn stop( + &self, + Parameters(_): Parameters, + ) -> Result { + match self.send_command_async(IpcCommand::Stop).await? { + IpcResponse::Ok(_) => Ok(CallToolResult::success(vec![Content::text( + "Playback stopped", + )])), + IpcResponse::Error { message } => Err(Self::mcp_error(message)), + } + } + + #[tool(description = "Get channel values at the current cursor position.")] + async fn get_cursor_values( + &self, + Parameters(req): Parameters, + ) -> Result { + 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(), + )])) + } + IpcResponse::Error { message } => Err(Self::mcp_error(message)), + _ => 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_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(), + )])) + } + IpcResponse::Error { message } => Err(Self::mcp_error(message)), + _ => 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_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, + })) => { + let result = serde_json::json!({ + "coefficient": coefficient, + "interpretation": interpretation + }); + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&result).unwrap_or_default(), + )])) + } + IpcResponse::Error { message } => Err(Self::mcp_error(message)), + _ => 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_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", + )])), + IpcResponse::Error { message } => Err(Self::mcp_error(message)), + } + } + + #[tool(description = "Switch back to time series chart view.")] + async fn show_chart( + &self, + Parameters(_): Parameters, + ) -> Result { + match self.send_command_async(IpcCommand::ShowChart).await? { + IpcResponse::Ok(_) => Ok(CallToolResult::success(vec![Content::text( + "Chart view displayed", + )])), + IpcResponse::Error { message } => Err(Self::mcp_error(message)), + } + } +} + +#[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(), + 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(), + ), + } + } +}