diff --git a/Cargo.lock b/Cargo.lock index 0b024d85..f20b0dd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -99,6 +99,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.100" @@ -448,6 +498,58 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "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 1.3.1", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.21.7" @@ -707,6 +809,69 @@ dependencies = [ "libc", ] +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "clipboard-win" version = "5.4.1" @@ -727,6 +892,12 @@ dependencies = [ "unicode-width 0.2.0", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -893,6 +1064,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -1013,8 +1193,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -1031,13 +1221,37 @@ dependencies = [ "syn", ] +[[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.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn", ] @@ -1201,6 +1415,12 @@ dependencies = [ "wio", ] +[[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.2" @@ -1789,6 +2009,7 @@ checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", + "futures-executor", "futures-io", "futures-sink", "futures-task", @@ -1811,6 +2032,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" @@ -1922,11 +2154,23 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", +] + [[package]] name = "gimli" version = "0.32.0" @@ -2313,6 +2557,7 @@ dependencies = [ "http 1.3.1", "http-body 1.0.1", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -2386,12 +2631,36 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.1", + "socket2 0.6.4", "tokio", "tower-service", "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +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 = "iced-x86" version = "1.21.0" @@ -2600,7 +2869,7 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a" dependencies = [ - "darling", + "darling 0.20.11", "indoc", "proc-macro2", "quote", @@ -2648,6 +2917,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.13.0" @@ -2777,9 +3052,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.177" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libloading" @@ -2893,6 +3168,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.7.6" @@ -2981,9 +3262,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "log", @@ -3606,6 +3887,23 @@ dependencies = [ "winit", ] +[[package]] +name = "objdiff-mcp" +version = "3.7.2" +dependencies = [ + "anyhow", + "axum", + "clap", + "objdiff-core", + "rmcp", + "schemars", + "serde", + "tokio", + "tracing", + "tracing-subscriber", + "typed-path", +] + [[package]] name = "objdiff-wasm" version = "3.7.2" @@ -3634,6 +3932,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "open" version = "5.3.3" @@ -3767,6 +4071,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pathdiff" version = "0.2.3" @@ -4028,9 +4338,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -4142,7 +4452,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls 0.23.35", - "socket2 0.6.1", + "socket2 0.6.4", "thiserror 2.0.17", "tokio", "tracing", @@ -4179,16 +4489,16 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.1", + "socket2 0.6.4", "tracing", "windows-sys 0.60.2", ] [[package]] name = "quote" -version = "1.0.42" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -4199,6 +4509,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rabbitizer" version = "2.0.0-alpha.7" @@ -4234,6 +4550,17 @@ dependencies = [ "rand_core 0.9.3", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -4259,6 +4586,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "range-alloc" version = "0.1.4" @@ -4361,6 +4694,26 @@ dependencies = [ "thiserror 2.0.17", ] +[[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" @@ -4531,6 +4884,50 @@ dependencies = [ "anyhow", ] +[[package]] +name = "rmcp" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f00a32c3b81b7b254076a65abd5ab2551209146713ba38f73818657e865e9433" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bytes", + "chrono", + "futures", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "pastey", + "pin-project-lite", + "rand 0.10.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 = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee70afb7956da9f30d5348a2539b5eb90d9038f834463657ab717076ac3b1ad" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "serde_json", + "syn", +] + [[package]] name = "ron" version = "0.11.0" @@ -4701,6 +5098,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -4825,6 +5248,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.145" @@ -4838,6 +5272,17 @@ dependencies = [ "serde_core", ] +[[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" @@ -4886,7 +5331,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -5072,12 +5517,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5095,6 +5540,19 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "sse-stream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3962b63f038885f15bce2c6e02c0e7925c072f1ac86bb60fd44c5c6b762fb72" +dependencies = [ + "bytes", + "futures-util", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -5158,9 +5616,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.110" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -5410,18 +5868,31 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.48.0" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", "mio", "pin-project-lite", - "socket2 0.6.1", + "signal-hook-registry", + "socket2 0.6.4", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokio-native-tls" version = "0.3.1" @@ -5576,6 +6047,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -5610,9 +6082,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -5622,9 +6094,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -5633,9 +6105,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -5654,9 +6126,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.20" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", @@ -5809,12 +6281,19 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ + "getrandom 0.3.4", "js-sys", "serde", "wasm-bindgen", diff --git a/Cargo.toml b/Cargo.toml index bf0537e0..17d6e00a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "objdiff-cli", "objdiff-core", "objdiff-gui", + "objdiff-mcp", "objdiff-wasm", ] default-members = [ diff --git a/objdiff-core/src/diff/code.rs b/objdiff-core/src/diff/code.rs index aaef971f..08c14e01 100644 --- a/objdiff-core/src/diff/code.rs +++ b/objdiff-core/src/diff/code.rs @@ -316,6 +316,53 @@ fn ins_data_literals_eq( left_literals == right_literals } +/// Strip the leading `?@?` of an MSVC function-local static's +/// decorated name, returning the stable `??@…@4@` tail +/// (or `None` if `name` isn't such a local static). +/// +/// MSVC decorates a function-local `static` as +/// `?@?@??@@4@`. Neither the +/// `` (the source's variable name — e.g. a macro's `info` vs +/// `__info`) nor the `` disambiguator (a compiler-assigned index that +/// shifts with the surrounding function body) is stable across recompiles, but +/// the enclosing function, type and storage class uniquely identify the static. +/// The `` is an MSVC mangled number: a single digit `0`..`9`, or one or +/// more base-16 nibbles `A`(0)..`P`(15) terminated by `@`. +fn canonical_local_static(name: &str) -> Option<&str> { + // `?@` + let rest = name.strip_prefix('?')?; + let at = rest.find('@')?; + let ident = &rest[..at]; + if ident.is_empty() || !ident.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') { + return None; + } + // `?` + let rest = rest[at + 1..].strip_prefix('?')?; + let tail = if rest.starts_with(|c: char| c.is_ascii_digit()) { + &rest[1..] + } else { + let end = rest.find('@')?; + let nibbles = &rest[..end]; + if nibbles.is_empty() || !nibbles.bytes().all(|b| (b'A'..=b'P').contains(&b)) { + return None; + } + &rest[end + 1..] + }; + // The remainder must open the enclosing nested name (`??@…`). + tail.starts_with("??").then_some(tail) +} + +/// True if both names are MSVC function-local statics of the same enclosing +/// function, type and storage class (ignoring their unstable local-name/scope +/// prefixes). Used to match e.g. `?info@?M@??f@…@4Uinfo@@B` against +/// `?__info@?6??f@…@4Uinfo@@B` — the same static under a different macro/compile. +fn local_static_eq(left: &str, right: &str) -> bool { + match (canonical_local_static(left), canonical_local_static(right)) { + (Some(a), Some(b)) => a == b, + _ => false, + } +} + fn reloc_eq( left_obj: &Object, right_obj: &Object, @@ -342,17 +389,32 @@ fn reloc_eq( && left_reloc.relocation.addend == right_reloc.relocation.addend; match (&left_reloc.symbol.section, &right_reloc.symbol.section) { (Some(sl), Some(sr)) => { - // Match if section and name or address match + // Match if section and name or address match. MSVC function-local + // statics (assert-info records, etc.) also match on their stable + // function+type tail — their local-name/scope decoration and their + // debug-metadata contents (message text, line number) legitimately + // vary across recompiles and are diffed as data separately. section_name_eq(left_obj, right_obj, *sl, *sr) - && (diff_config.function_reloc_diffs == FunctionRelocDiffs::DataValue - || symbol_name_addend_matches - || address_eq(left_reloc, right_reloc)) - && (diff_config.function_reloc_diffs == FunctionRelocDiffs::NameAddress - || left_reloc.symbol.kind != SymbolKind::Object - || right_reloc.symbol.size == 0 // Likely a pool symbol like ...data, don't treat this as a diff - || ins_data_literals_eq(left_obj, right_obj, left_ins, right_ins, diff_config)) + && (local_static_eq(&left_reloc.symbol.name, &right_reloc.symbol.name) + || ((diff_config.function_reloc_diffs == FunctionRelocDiffs::DataValue + || symbol_name_addend_matches + || address_eq(left_reloc, right_reloc)) + && (diff_config.function_reloc_diffs == FunctionRelocDiffs::NameAddress + || left_reloc.symbol.kind != SymbolKind::Object + || right_reloc.symbol.size == 0 // Likely a pool symbol like ...data, don't treat this as a diff + || ins_data_literals_eq( + left_obj, right_obj, left_ins, right_ins, diff_config, + )))) + } + // Section-less (external/COMDAT) references — e.g. an inline function's + // assert-info static referenced from another object. Match by exact name + // or, for MSVC function-local statics, by the stable function+type tail + // at the same addend (their local-name/scope decoration is unstable). + (Some(_), None) | (None, Some(_)) | (None, None) => { + symbol_name_addend_matches + || (left_reloc.relocation.addend == right_reloc.relocation.addend + && local_static_eq(&left_reloc.symbol.name, &right_reloc.symbol.name)) } - (Some(_), None) | (None, Some(_)) | (None, None) => symbol_name_addend_matches, } } @@ -532,3 +594,58 @@ fn diff_instruction( Ok(InstructionDiffResult::new(InstructionDiffKind::None)) } + +#[cfg(test)] +mod tests { + use super::{canonical_local_static, local_static_eq}; + + #[test] + fn canonical_local_static_strips_name_and_scope() { + // Digit scope (`?6`) and macro name `__info`. + assert_eq!( + canonical_local_static("?__info@?6??acquire@?$c_reference_count@F@@QEAAXXZ@4Us_slim_assert_info@@B"), + Some("??acquire@?$c_reference_count@F@@QEAAXXZ@4Us_slim_assert_info@@B") + ); + // Nibble scope (`?M@`) and source name `info` — the reference side. + assert_eq!( + canonical_local_static("?info@?M@??acquire@?$c_reference_count@F@@QEAAXXZ@4Us_slim_assert_info@@B"), + Some("??acquire@?$c_reference_count@F@@QEAAXXZ@4Us_slim_assert_info@@B") + ); + } + + #[test] + fn local_static_eq_matches_across_name_and_scope() { + // Same function/type/storage, different local name + scope index → equal. + assert!(local_static_eq( + "?info@?M@??acquire@?$c_reference_count@F@@QEAAXXZ@4Us_slim_assert_info@@B", + "?__info@?6??acquire@?$c_reference_count@F@@QEAAXXZ@4Us_slim_assert_info@@B" + )); + } + + #[test] + fn local_static_eq_rejects_different_functions() { + // Different enclosing function → not equal, even with matching name/type. + assert!(!local_static_eq( + "?__info@?6??acquire@?$c_reference_count@F@@QEAAXXZ@4Us_slim_assert_info@@B", + "?__info@?6??release@?$c_reference_count@F@@QEAAXXZ@4Us_slim_assert_info@@B" + )); + } + + #[test] + fn local_static_eq_rejects_different_types() { + // Same function, different static type → not equal. + assert!(!local_static_eq( + "?__info@?6??acquire@?$c_reference_count@F@@QEAAXXZ@4Us_slim_assert_info@@B", + "?__info@?6??acquire@?$c_reference_count@F@@QEAAXXZ@4Uother_type@@B" + )); + } + + #[test] + fn canonical_local_static_rejects_non_locals() { + // Ordinary global/function symbols must not be treated as local statics. + assert_eq!(canonical_local_static("?acquire@?$c_reference_count@F@@QEAAXXZ"), None); + assert_eq!(canonical_local_static("?g_some_global@@3HA"), None); + // Missing the `??` nested-name introducer after the scope. + assert_eq!(canonical_local_static("?info@?6@not_a_nested_name"), None); + } +} diff --git a/objdiff-mcp/Cargo.toml b/objdiff-mcp/Cargo.toml new file mode 100644 index 00000000..c3e18256 --- /dev/null +++ b/objdiff-mcp/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "objdiff-mcp" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = """ +Model Context Protocol (MCP) server exposing objdiff's diffing for AI-driven decompilation matching. +""" + +[[bin]] +name = "objdiff-mcp" +path = "src/main.rs" + +[dependencies] +objdiff-core = { path = "../objdiff-core", features = ["all"] } +anyhow = "1.0" +rmcp = { version = "2.1.0", features = ["server", "transport-io", "transport-streamable-http-server"] } +tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "io-std", "signal", "sync"] } +schemars = "1" +serde = { version = "1.0.228", features = ["derive"] } +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +clap = { version = "4.6.1", features = ["derive"] } +axum = "0.8.9" +typed-path = "0.12" diff --git a/objdiff-mcp/README.md b/objdiff-mcp/README.md new file mode 100644 index 00000000..d8a38bde --- /dev/null +++ b/objdiff-mcp/README.md @@ -0,0 +1,100 @@ +# objdiff-mcp + +A [Model Context Protocol](https://modelcontextprotocol.io) server that exposes +[objdiff](https://github.com/encounter/objdiff)'s diffing engine so a model can +drive decompilation matching without any UI. + +It's designed to close the loop with an IDA bridge: read the reference in IDA → +write/adjust C/C++ → compile to an object → **`diff_function`** against the +baseline → read the per-instruction diff → repeat until 100%. + +``` + IDA (reference) decomp project objdiff-mcp (this) + ida-bridge MCP → edit C++ → build → diff_function → match% + instr diff + ▲ │ + └────────────── agent iterates ◀────────────┘ +``` + +## Build + +```bash +cargo build --release -p objdiff-mcp +# binary: target/release/objdiff-mcp +``` + +Built on `objdiff-core` (all architectures: ARM, ARM64, MIPS, PPC, SuperH, +x86/x86_64) with COFF + ELF support. + +## Run + +The server is persistent — it runs until closed and holds project/config state +across calls. + +**Persistent HTTP instance** (recommended; e.g. on the Windows build VM next to +the compiled objects, reached from the agent over the network): + +```bash +objdiff-mcp --transport http --bind 0.0.0.0:3001 [--project C:\path\to\project] +# MCP endpoint: http://:3001/mcp +``` + +**stdio** (spawned by the client via `.mcp.json`): + +```bash +objdiff-mcp # --transport stdio is the default +``` + +Logs go to stderr; stdout is reserved for the protocol on stdio. + +## Tools + +| Tool | Purpose | +|---|---| +| `open_project` | Load an `objdiff.json` so later calls refer to **units** by name instead of file paths. | +| `list_units` | List the project's units with their resolved target/base object paths (optional name filter). | +| `build` | Run the project's build command for a unit's base (or target) object; returns command line, exit status, and compiler output. | +| `diff_function` | Diff one function between the target (expected/baseline) and base (current/your build). Returns the match percent and a **side-by-side, per-instruction diff** with mismatch markers. The primary matching tool. | +| `diff_overview` | List every function in the object pair with its match percent, worst first. Use to pick what to work on. | +| `set_config` | Set a persistent objdiff config option (e.g. `x86.formatter`, `spaceBetweenArgs`, `demangler`) applied to subsequent diffs. | +| `version` | Report the server version. | + +`diff_function` / `diff_overview` take **either** a project `unit` **or** explicit +`target`+`base` object-file paths, plus an optional per-call `config` map of +objdiff config overrides. Mismatch marker legend: +`~` replace · `o` opcode-mismatch · `a` arg-mismatch · `+` insert · `-` delete. + +## The matching loop + +1. `open_project` once (or `--project` at startup). +2. Understand the target function in IDA (via the IDA bridge). +3. Edit the C/C++ for the unit. +4. `build` the unit. +5. `diff_function(unit, symbol)` — read the match % and the side-by-side diff. +6. Adjust based on the mismatching instructions; cross-check offsets/targets in + IDA. Go to 3. Repeat until 100%. + +Use `diff_overview(unit, only_mismatches=true)` to triage which functions to +attack first. + +## Connecting the agent + +**HTTP (shared instance):** point your MCP client at `http://:3001/mcp`. + +**stdio (`.mcp.json`):** + +```json +{ + "mcpServers": { + "objdiff": { "command": "/path/to/objdiff-mcp" } + } +} +``` + +## Note on the baseline + +objdiff compares two objects: the **target** (the original/expected function's +machine code, the "baseline") and the **base** (your current build). Producing +the baseline object — extracting the original function's bytes into a COFF/ELF +object with name/xref-derived relocations — is a build/extraction step outside +objdiff (best done IDA-side, where the names and xrefs live). Point `target_path` +in `objdiff.json` at that file. diff --git a/objdiff-mcp/src/diff.rs b/objdiff-mcp/src/diff.rs new file mode 100644 index 00000000..417760cb --- /dev/null +++ b/objdiff-mcp/src/diff.rs @@ -0,0 +1,144 @@ +//! Core diffing helpers built directly on `objdiff-core`. +//! +//! These are UI-free and synchronous; the MCP server layer wraps them in +//! `spawn_blocking` and turns the results into tool responses. + +use std::{fmt::Write as _, path::Path}; + +use anyhow::{Context, Result}; +use objdiff_core::{ + bindings::diff::{DiffKind, DiffObject, DiffResult, DiffSymbol, DiffSymbolKind}, + diff::{DiffObjConfig, DiffSide, MappingConfig, diff_objs}, + obj::read, +}; + +/// Load target + base objects from disk and produce a full serializable diff. +/// +/// `target` is the expected/baseline object (left); `base` is your current +/// build (right). +pub fn run_diff(target: &Path, base: &Path, config: &DiffObjConfig) -> Result { + let target_obj = read::read(target, config, DiffSide::Target) + .with_context(|| format!("Failed to read target object {}", target.display()))?; + let base_obj = read::read(base, config, DiffSide::Base) + .with_context(|| format!("Failed to read base object {}", base.display()))?; + let result = + diff_objs(Some(&target_obj), Some(&base_obj), None, config, &MappingConfig::default()) + .context("Failed to diff objects")?; + DiffResult::new( + result.left.as_ref().map(|d| (&target_obj, d)), + result.right.as_ref().map(|d| (&base_obj, d)), + config, + ) + .context("Failed to build diff result") +} + +fn kind_marker(kind: DiffKind) -> &'static str { + match kind { + DiffKind::DiffNone => " ", + DiffKind::DiffReplace => "~", + DiffKind::DiffDelete => "-", + DiffKind::DiffInsert => "+", + DiffKind::DiffOpMismatch => "o", + DiffKind::DiffArgMismatch => "a", + } +} + +fn is_function(sym: &DiffSymbol) -> bool { + DiffSymbolKind::try_from(sym.kind).unwrap_or(DiffSymbolKind::SymbolUnknown) + == DiffSymbolKind::SymbolFunction +} + +/// A compact, token-efficient overview of every code symbol and its match %. +pub fn overview(diff: &DiffResult, min_only_mismatches: bool, limit: usize) -> String { + let Some(right) = diff.right.as_ref() else { + return "No base object in diff result.".to_string(); + }; + let mut rows: Vec<(&str, f32, u64)> = right + .symbols + .iter() + .filter(|s| is_function(s)) + .map(|s| (s.name.as_str(), s.match_percent.unwrap_or(0.0), s.size)) + .collect(); + // Worst matches first — that's what you want to work on. + rows.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)); + if min_only_mismatches { + rows.retain(|r| r.1 < 100.0); + } + + let mut out = String::new(); + let total = rows.len(); + let _ = writeln!(out, "{total} function(s) (worst match first):"); + for (name, pct, size) in rows.into_iter().take(limit) { + let _ = writeln!(out, " {pct:6.2}% {size:>6} {name}"); + } + out +} + +fn instr_text(sym: Option<&DiffSymbol>, i: usize) -> (&str, Option) { + match sym.and_then(|s| s.instructions.get(i)) { + Some(row) => match row.instruction.as_ref() { + Some(ins) => (ins.formatted.as_str(), Some(ins.address)), + None => ("", None), + }, + None => ("", None), + } +} + +/// Render a single function's diff as a side-by-side (target vs current) table +/// with per-row mismatch markers — the primary signal for iterative matching. +pub fn function_diff(diff: &DiffResult, symbol: &str) -> Result { + let right = diff.right.as_ref().context("No base object in diff result")?; + let left = diff.left.as_ref(); + + let base_sym = right + .symbols + .iter() + .find(|s| s.name == symbol) + .with_context(|| format!("Symbol `{symbol}` not found in base (current) object"))?; + + let target_sym: Option<&DiffSymbol> = base_sym + .target_symbol + .and_then(|ti| left.and_then(|l: &DiffObject| l.symbols.get(ti as usize))); + + let pct = base_sym.match_percent.unwrap_or(0.0); + let mut out = String::new(); + let _ = writeln!(out, "symbol : {symbol}"); + if let Some(dm) = &base_sym.demangled_name { + let _ = writeln!(out, "demangled: {dm}"); + } + let _ = writeln!(out, "match : {pct:.2}%"); + match target_sym { + Some(_) => {} + None => { + let _ = writeln!( + out, + "note : no matched symbol in target object (unmatched — nothing to compare against)" + ); + } + } + let _ = writeln!( + out, + "legend : ' '=equal ~=replace o=opcode-mismatch a=arg-mismatch +=insert -=delete" + ); + let _ = writeln!(out); + let _ = writeln!( + out, + " {:<8} {:1} {:<38} {:<38}", + "addr", "", "target (expected)", "current (yours)" + ); + + let n = base_sym.instructions.len().max(target_sym.map(|s| s.instructions.len()).unwrap_or(0)); + for i in 0..n { + let (rtext, raddr) = instr_text(Some(base_sym), i); + let (ltext, laddr) = instr_text(target_sym, i); + let kind = base_sym + .instructions + .get(i) + .map(|r| DiffKind::try_from(r.diff_kind).unwrap_or(DiffKind::DiffNone)) + .unwrap_or(DiffKind::DiffInsert); + let addr = raddr.or(laddr).unwrap_or(0); + let _ = + writeln!(out, " {:08x} {:1} {:<38} {:<38}", addr, kind_marker(kind), ltext, rtext); + } + Ok(out) +} diff --git a/objdiff-mcp/src/main.rs b/objdiff-mcp/src/main.rs new file mode 100644 index 00000000..3b67e9a4 --- /dev/null +++ b/objdiff-mcp/src/main.rs @@ -0,0 +1,91 @@ +//! objdiff-mcp: a Model Context Protocol server exposing objdiff's diffing for +//! AI-driven decompilation matching. +//! +//! Runs persistently ("prompt as you go") over either transport: +//! * `--transport stdio` (default) for a client that spawns it (e.g. `.mcp.json`) +//! * `--transport http` for a long-lived shared instance reachable over the network + +mod diff; +mod project; +mod server; +mod state; + +use std::sync::Arc; + +use anyhow::Result; +use clap::Parser; +use rmcp::{ + ServiceExt, + transport::{ + stdio, + streamable_http_server::{ + StreamableHttpService, session::local::LocalSessionManager, + tower::StreamableHttpServerConfig, + }, + }, +}; + +use crate::{server::ObjdiffServer, state::AppState}; + +#[derive(Parser, Debug)] +#[command(name = "objdiff-mcp", about = "MCP server for objdiff decompilation matching")] +struct Args { + /// Transport: "stdio" or "http". + #[arg(long, default_value = "stdio")] + transport: String, + /// Bind address for the http transport. + #[arg(long, default_value = "127.0.0.1:3001")] + bind: String, + /// Optionally remember a project directory on startup. + #[arg(long)] + project: Option, +} + +#[tokio::main] +async fn main() -> Result<()> { + // Logs MUST go to stderr; stdout is the MCP protocol channel on stdio. + tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), + ) + .init(); + + let args = Args::parse(); + let app_state = Arc::new(AppState::new()); + if let Some(dir) = args.project.as_deref() { + match app_state.open_project(&dir.to_string_lossy()) { + Ok(summary) => tracing::info!("{summary}"), + Err(e) => tracing::warn!("Failed to open project: {e:#}"), + } + } + + match args.transport.as_str() { + "stdio" => { + tracing::info!("objdiff-mcp starting on stdio"); + let service = ObjdiffServer::new(app_state).serve(stdio()).await?; + service.waiting().await?; + } + "http" => { + let factory_state = app_state.clone(); + let service = StreamableHttpService::new( + move || Ok(ObjdiffServer::new(factory_state.clone())), + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default(), + ); + let router = axum::Router::new().nest_service("/mcp", service); + let listener = tokio::net::TcpListener::bind(&args.bind).await?; + tracing::info!("objdiff-mcp listening on http://{}/mcp", args.bind); + axum::serve(listener, router) + .with_graceful_shutdown(async { + let _ = tokio::signal::ctrl_c().await; + tracing::info!("shutting down"); + }) + .await?; + } + other => { + anyhow::bail!("Unknown transport `{other}` (expected `stdio` or `http`)"); + } + } + Ok(()) +} diff --git a/objdiff-mcp/src/project.rs b/objdiff-mcp/src/project.rs new file mode 100644 index 00000000..5af32e80 --- /dev/null +++ b/objdiff-mcp/src/project.rs @@ -0,0 +1,165 @@ +//! Project (`objdiff.json`) awareness: load a config, resolve a unit's +//! target/base object paths exactly like objdiff does, merge diff options, +//! and run the project's build command. + +use std::path::PathBuf; + +use anyhow::{Context, Result, anyhow, bail}; +use objdiff_core::{ + build::{BuildConfig, BuildStatus}, + config::{ProjectConfig, ProjectConfigInfo, ProjectObject, apply_project_options}, + diff::DiffObjConfig, +}; +use typed_path::Utf8PlatformPathBuf; + +/// A loaded project plus its directory (native encoding). +pub struct LoadedProject { + pub dir: Utf8PlatformPathBuf, + pub config: ProjectConfig, + pub info: ProjectConfigInfo, +} + +impl LoadedProject { + pub fn load(dir: &str) -> Result { + let native = PathBuf::from(dir); + let (result, info) = objdiff_core::config::try_project_config(&native) + .with_context(|| format!("No objdiff.json/yml found in {dir}"))?; + let config = result.context("Failed to parse project config")?; + Ok(Self { dir: Utf8PlatformPathBuf::from(dir), config, info }) + } + + fn object(&self, unit: &str) -> Result<&ProjectObject> { + self.config + .units() + .iter() + .find(|o| o.name() == unit) + .with_context(|| format!("Unit `{unit}` not found in project")) + } + + /// Resolve a unit's (target, base) object paths, mirroring objdiff's + /// `ObjectConfig::new` path logic. + pub fn resolve_paths(&self, unit: &str) -> Result<(Option, Option)> { + let obj = self.object(unit)?; + let target_dir = + self.config.target_dir.as_ref().map(|d| self.dir.join(d.with_platform_encoding())); + let base_dir = + self.config.base_dir.as_ref().map(|d| self.dir.join(d.with_platform_encoding())); + + let target = match (&target_dir, &obj.path, &obj.target_path) { + (Some(td), Some(path), None) => Some(td.join(path.with_platform_encoding())), + _ => obj.target_path.as_ref().map(|p| self.dir.join(p.with_platform_encoding())), + }; + let base = match (&base_dir, &obj.path, &obj.base_path) { + (Some(bd), Some(path), None) => Some(bd.join(path.with_platform_encoding())), + _ => obj.base_path.as_ref().map(|p| self.dir.join(p.with_platform_encoding())), + }; + Ok((target.map(to_std), base.map(to_std))) + } + + /// Build the diff config for a unit: project options then unit options. + pub fn apply_options(&self, unit: Option<&str>, config: &mut DiffObjConfig) -> Result<()> { + if let Some(options) = &self.config.options { + apply_project_options(config, options)?; + } + if let Some(unit) = unit + && let Some(options) = &self.object(unit)?.options + { + apply_project_options(config, options)?; + } + Ok(()) + } + + /// Prepare a build invocation (config + relative unix object path) for a + /// unit's base (or target) object. The caller runs `run_make` off-lock. + pub fn build_invocation( + &self, + unit: &str, + target: bool, + ) -> Result<(BuildConfig, typed_path::Utf8UnixPathBuf)> { + let (target_path, base_path) = self.resolve_paths(unit)?; + let path = if target { target_path } else { base_path }; + let path = path.ok_or_else(|| { + anyhow!("Unit `{unit}` has no {} path", if target { "target" } else { "base" }) + })?; + let path_str = path.to_string_lossy().into_owned(); + let path_pp = Utf8PlatformPathBuf::from(path_str); + let rel = path_pp.strip_prefix(&self.dir).map_err(|_| { + anyhow!("Object path `{path_pp}` is not inside project dir `{}`", self.dir) + })?; + let rel_unix = rel.with_unix_encoding(); + let build_config = BuildConfig { + project_dir: Some(self.dir.clone()), + custom_make: self.config.custom_make.clone(), + custom_args: self.config.custom_args.clone(), + selected_wsl_distro: None, + }; + Ok((build_config, rel_unix)) + } + + /// One-line-per-unit listing with resolved path presence. + pub fn list_units(&self, filter: Option<&str>) -> String { + use std::fmt::Write as _; + let mut out = String::new(); + let units = self.config.units(); + let _ = writeln!(out, "{} unit(s):", units.len()); + for obj in units { + let name = obj.name(); + if let Some(f) = filter + && !name.contains(f) + { + continue; + } + let (t, b) = self.resolve_paths(name).unwrap_or((None, None)); + let _ = writeln!( + out, + " {name} [target: {}] [base: {}]", + t.map(|p| p.display().to_string()).unwrap_or_else(|| "-".into()), + b.map(|p| p.display().to_string()).unwrap_or_else(|| "-".into()), + ); + } + out + } +} + +fn to_std(p: Utf8PlatformPathBuf) -> PathBuf { PathBuf::from(p.as_str()) } + +/// Ensure a build succeeded, otherwise surface stdout/stderr. +pub fn build_status_text(status: &BuildStatus) -> String { + let mut out = format!( + "$ {}\nexit: {}\n", + status.cmdline, + if status.success { "success" } else { "FAILED" } + ); + if !status.stdout.is_empty() { + out.push_str("--- stdout ---\n"); + out.push_str(&status.stdout); + out.push('\n'); + } + if !status.stderr.is_empty() { + out.push_str("--- stderr ---\n"); + out.push_str(&status.stderr); + out.push('\n'); + } + out +} + +/// Resolve final (target, base) std paths from either explicit paths or a unit. +pub fn resolve_inputs( + project: Option<&LoadedProject>, + unit: Option<&str>, + target: Option<&str>, + base: Option<&str>, +) -> Result<(PathBuf, PathBuf)> { + if let Some(unit) = unit { + let project = project.context("No project loaded; call open_project first")?; + let (t, b) = project.resolve_paths(unit)?; + let t = t.with_context(|| format!("Unit `{unit}` has no target path"))?; + let b = b.with_context(|| format!("Unit `{unit}` has no base path"))?; + Ok((t, b)) + } else { + match (target, base) { + (Some(t), Some(b)) => Ok((PathBuf::from(t), PathBuf::from(b))), + _ => bail!("Provide either `unit` (with a loaded project) or both `target` and `base`"), + } + } +} diff --git a/objdiff-mcp/src/server.rs b/objdiff-mcp/src/server.rs new file mode 100644 index 00000000..d534fb4f --- /dev/null +++ b/objdiff-mcp/src/server.rs @@ -0,0 +1,224 @@ +//! rmcp server exposing objdiff's diffing as MCP tools. + +use std::{collections::BTreeMap, sync::Arc}; + +use objdiff_core::build::run_make; +use rmcp::{ + ErrorData, ServerHandler, + handler::server::{router::tool::ToolRouter, wrapper::Parameters}, + model::{CallToolResult, ContentBlock, ServerCapabilities, ServerInfo}, + tool, tool_handler, tool_router, +}; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::{diff, project, state::AppState}; + +#[derive(Clone)] +pub struct ObjdiffServer { + state: Arc, + // Read by the `#[tool_handler]`-generated dispatch code. + #[allow(dead_code)] + tool_router: ToolRouter, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct OpenProjectArgs { + /// Directory containing an `objdiff.json` (or `.yml`/`.yaml`). + pub dir: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListUnitsArgs { + /// Optional substring filter on unit names. + #[serde(default)] + pub filter: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct BuildArgs { + /// Unit (translation-unit) name to build, from the loaded project. + pub unit: String, + /// Build the target/expected object instead of your base build (default false). + #[serde(default)] + pub target: bool, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DiffFunctionArgs { + /// Unit name from the loaded project (alternative to target/base paths). + #[serde(default)] + pub unit: Option, + /// Path to the target/expected object file (the baseline). Use with `base`. + #[serde(default)] + pub target: Option, + /// Path to the base/current object file produced by your build. Use with `target`. + #[serde(default)] + pub base: Option, + /// Name of the function symbol to diff. + pub symbol: String, + /// Optional objdiff config overrides, e.g. `{"x86.formatter": "intel"}`. + #[serde(default)] + pub config: BTreeMap, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DiffOverviewArgs { + /// Unit name from the loaded project (alternative to target/base paths). + #[serde(default)] + pub unit: Option, + /// Path to the target/expected object file (the baseline). Use with `base`. + #[serde(default)] + pub target: Option, + /// Path to the base/current object file produced by your build. Use with `target`. + #[serde(default)] + pub base: Option, + /// Only list functions that are not already 100% matched. + #[serde(default)] + pub only_mismatches: bool, + /// Maximum number of functions to list (default 200). + #[serde(default)] + pub limit: Option, + /// Optional objdiff config overrides. + #[serde(default)] + pub config: BTreeMap, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SetConfigArgs { + /// Config property id, e.g. `x86.formatter` or `spaceBetweenArgs`. + pub key: String, + /// New value for the property. + pub value: String, +} + +fn err(e: anyhow::Error) -> ErrorData { ErrorData::internal_error(format!("{e:#}"), None) } + +fn join_err(e: tokio::task::JoinError) -> ErrorData { + ErrorData::internal_error(format!("task join error: {e}"), None) +} + +fn text(s: String) -> CallToolResult { CallToolResult::success(vec![ContentBlock::text(s)]) } + +#[tool_router] +impl ObjdiffServer { + pub fn new(state: Arc) -> Self { Self { state, tool_router: Self::tool_router() } } + + #[tool(description = "Report the objdiff-mcp / objdiff-core version.")] + async fn version(&self) -> Result { + Ok(text(format!("objdiff-mcp {} (objdiff-core embedded)", env!("CARGO_PKG_VERSION")))) + } + + #[tool( + description = "Load an objdiff project (objdiff.json) so later tools can refer to units \ + by name instead of file paths." + )] + async fn open_project( + &self, + Parameters(args): Parameters, + ) -> Result { + Ok(text(self.state.open_project(&args.dir).map_err(err)?)) + } + + #[tool(description = "List units in the loaded project with their resolved target/base paths.")] + async fn list_units( + &self, + Parameters(args): Parameters, + ) -> Result { + Ok(text(self.state.list_units(args.filter.as_deref()).map_err(err)?)) + } + + #[tool(description = "Diff a single function between the target (expected/baseline) and base \ + (current/your build) objects. Specify either a project `unit` or explicit `target`+`base` \ + paths. Returns the overall match percent and a side-by-side, per-instruction diff with \ + mismatch markers. The primary tool for iterating C/C++ toward a 100% match.")] + async fn diff_function( + &self, + Parameters(args): Parameters, + ) -> Result { + let inputs = self + .state + .prepare_diff( + args.unit.as_deref(), + args.target.as_deref(), + args.base.as_deref(), + &args.config, + ) + .map_err(err)?; + let symbol = args.symbol; + let out = tokio::task::spawn_blocking(move || -> anyhow::Result { + let result = diff::run_diff(&inputs.target, &inputs.base, &inputs.config)?; + diff::function_diff(&result, &symbol) + }) + .await + .map_err(join_err)? + .map_err(err)?; + Ok(text(out)) + } + + #[tool(description = "List every function in the object pair with its match percent, worst \ + matches first. Specify either a project `unit` or explicit `target`+`base` paths.")] + async fn diff_overview( + &self, + Parameters(args): Parameters, + ) -> Result { + let inputs = self + .state + .prepare_diff( + args.unit.as_deref(), + args.target.as_deref(), + args.base.as_deref(), + &args.config, + ) + .map_err(err)?; + let only = args.only_mismatches; + let limit = args.limit.unwrap_or(200); + let out = tokio::task::spawn_blocking(move || -> anyhow::Result { + let result = diff::run_diff(&inputs.target, &inputs.base, &inputs.config)?; + Ok(diff::overview(&result, only, limit)) + }) + .await + .map_err(join_err)? + .map_err(err)?; + Ok(text(out)) + } + + #[tool(description = "Run the loaded project's build command for a unit's base (or target) \ + object. Returns the command line, exit status, and compiler output.")] + async fn build( + &self, + Parameters(args): Parameters, + ) -> Result { + let (build_config, rel) = + self.state.build_invocation(&args.unit, args.target).map_err(err)?; + let status = tokio::task::spawn_blocking(move || run_make(&build_config, &rel)) + .await + .map_err(join_err)?; + Ok(text(project::build_status_text(&status))) + } + + #[tool(description = "Set a persistent objdiff diff/disassembly config option (applies to \ + subsequent diffs). Example keys: `x86.formatter`, `spaceBetweenArgs`, `demangler`.")] + async fn set_config( + &self, + Parameters(args): Parameters, + ) -> Result { + self.state.set_config(&args.key, &args.value).map_err(err)?; + Ok(text(format!("Set `{}` = `{}`", args.key, args.value))) + } +} + +#[tool_handler] +impl ServerHandler for ObjdiffServer { + fn get_info(&self) -> ServerInfo { + let mut info = ServerInfo::default(); + info.capabilities = ServerCapabilities::builder().enable_tools().build(); + info.instructions = Some( + "objdiff MCP server for decompilation matching. Load a project with open_project, \ + build a unit, then use diff_function to compare your build against the baseline and \ + iterate toward 100%." + .to_string(), + ); + info + } +} diff --git a/objdiff-mcp/src/state.rs b/objdiff-mcp/src/state.rs new file mode 100644 index 00000000..e80829be --- /dev/null +++ b/objdiff-mcp/src/state.rs @@ -0,0 +1,106 @@ +//! Shared, long-lived server state. +//! +//! The server is meant to run persistently ("prompt as you go"), so state is +//! shared across every stdio session and HTTP connection via an `Arc`. + +use std::{collections::BTreeMap, path::PathBuf, sync::Mutex}; + +use anyhow::{Result, anyhow}; +use objdiff_core::{ + build::BuildConfig, + diff::{ConfigPropertyId, DiffObjConfig}, +}; +use typed_path::Utf8UnixPathBuf; + +use crate::project::{LoadedProject, resolve_inputs}; + +#[derive(Default)] +pub struct AppState { + inner: Mutex, +} + +#[derive(Default)] +struct Inner { + /// Baseline diff config; project/unit options and per-call overrides layer on top. + config: DiffObjConfig, + /// Currently loaded project, if any. + project: Option, +} + +/// Everything needed to run one diff, resolved off-lock. +pub struct DiffInputs { + pub target: PathBuf, + pub base: PathBuf, + pub config: DiffObjConfig, +} + +impl AppState { + pub fn new() -> Self { Self::default() } + + /// Apply a single config override onto the persistent baseline config. + pub fn set_config(&self, key: &str, value: &str) -> Result<()> { + let id = parse_property(key)?; + let mut inner = self.inner.lock().unwrap(); + inner + .config + .set_property_value_str(id, value) + .map_err(|_| anyhow!("Invalid value `{value}` for config property `{key}`")) + } + + pub fn open_project(&self, dir: &str) -> Result { + let project = LoadedProject::load(dir)?; + let summary = format!( + "Loaded {} ({} units)", + project.info.path.display(), + project.config.units().len() + ); + self.inner.lock().unwrap().project = Some(project); + Ok(summary) + } + + pub fn list_units(&self, filter: Option<&str>) -> Result { + let inner = self.inner.lock().unwrap(); + let project = inner.project.as_ref().ok_or_else(no_project)?; + Ok(project.list_units(filter)) + } + + /// Resolve target/base paths and the final diff config for one diff. + pub fn prepare_diff( + &self, + unit: Option<&str>, + target: Option<&str>, + base: Option<&str>, + overrides: &BTreeMap, + ) -> Result { + let inner = self.inner.lock().unwrap(); + let (target, base) = resolve_inputs(inner.project.as_ref(), unit, target, base)?; + let mut config = inner.config.clone(); + if let Some(project) = inner.project.as_ref() { + project.apply_options(unit, &mut config)?; + } + for (key, value) in overrides { + let id = parse_property(key)?; + config + .set_property_value_str(id, value) + .map_err(|_| anyhow!("Invalid value `{value}` for config property `{key}`"))?; + } + Ok(DiffInputs { target, base, config }) + } + + /// Prepare a build invocation for a unit (off-lock execution by the caller). + pub fn build_invocation( + &self, + unit: &str, + target: bool, + ) -> Result<(BuildConfig, Utf8UnixPathBuf)> { + let inner = self.inner.lock().unwrap(); + let project = inner.project.as_ref().ok_or_else(no_project)?; + project.build_invocation(unit, target) + } +} + +fn parse_property(key: &str) -> Result { + key.parse::().map_err(|_| anyhow!("Unknown config property `{key}`")) +} + +fn no_project() -> anyhow::Error { anyhow!("No project loaded; call open_project first") }