From 7cb0cfc249ec5012892d98bdca369dc3bab254b9 Mon Sep 17 00:00:00 2001 From: Camden Smallwood Date: Thu, 2 Jul 2026 14:10:37 -0400 Subject: [PATCH 1/2] Add objdiff-mcp: MCP server for AI-driven decomp matching New workspace crate `objdiff-mcp` exposing objdiff's diffing over the Model Context Protocol (rmcp 2.1) so a model can drive matching without UI. Runs persistently over stdio (--transport stdio) or a shared HTTP instance (--transport http --bind ...). State (loaded project + diff config) is shared across sessions for the life of the process. Tools: - open_project / list_units: load objdiff.json, refer to units by name - diff_function: side-by-side per-instruction diff + match% for one function - diff_overview: functions ranked worst-match-first - build: run the project's build command for a unit (via objdiff-core run_make) - set_config: persistent diff/disassembly config overrides - version diff_function / diff_overview accept either a project unit or explicit target/base object paths, plus per-call config overrides. Added to workspace members (not default-members) to keep default builds lean. --- Cargo.lock | 545 ++++++++++++++++++++++++++++++++++--- Cargo.toml | 1 + objdiff-mcp/Cargo.toml | 28 ++ objdiff-mcp/README.md | 100 +++++++ objdiff-mcp/src/diff.rs | 144 ++++++++++ objdiff-mcp/src/main.rs | 91 +++++++ objdiff-mcp/src/project.rs | 165 +++++++++++ objdiff-mcp/src/server.rs | 224 +++++++++++++++ objdiff-mcp/src/state.rs | 106 ++++++++ 9 files changed, 1371 insertions(+), 33 deletions(-) create mode 100644 objdiff-mcp/Cargo.toml create mode 100644 objdiff-mcp/README.md create mode 100644 objdiff-mcp/src/diff.rs create mode 100644 objdiff-mcp/src/main.rs create mode 100644 objdiff-mcp/src/project.rs create mode 100644 objdiff-mcp/src/server.rs create mode 100644 objdiff-mcp/src/state.rs 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-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") } From abe421cbfb735c61f45a300021a0f6b7f72331c8 Mon Sep 17 00:00:00 2001 From: Camden Smallwood Date: Fri, 3 Jul 2026 08:39:25 -0700 Subject: [PATCH 2/2] Match MSVC function-local statics by stable function+type tail MSVC decorates a function-local static as ?@?@??@@4@. The local name and scope disambiguator are unstable across recompiles, but the enclosing function, type and storage class uniquely identify the static. Match relocations to such statics on that stable tail, including section-less external/COMDAT references. --- objdiff-core/src/diff/code.rs | 135 +++++++++++++++++++++++++++++++--- 1 file changed, 126 insertions(+), 9 deletions(-) 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); + } +}