mirror of
https://github.com/emilk/egui.git
synced 2026-06-26 22:53:14 -04:00
Add egui_kittest_mcp server
New binary crate that exposes an MCP (Model Context Protocol) server backed by the `egui_inspection` protocol. The server bridges a running egui peer — a spawned `egui_kittest` harness child process or an attached live `eframe` app — to MCP tool handlers that drive it. Components: - `bridge.rs`: spawns / attaches a peer over a unix socket, runs reader+writer Tokio tasks that pump `HarnessMessage` ↔ `InspectorCommand` and track the peer's `Hello`, latest frame, accesskit tree, and blocked / finished state. - `tools.rs`: `rmcp`-derived tool router with commands for stepping, event injection (click / type / scroll / hover / drag / keys), resizing, screenshot capture, accesskit tree queries, and lifecycle (launch / attach / kill). - `tree.rs`: accesskit-tree projection helpers shared by the tools. - `shim.rs` / `main.rs`: shim role that lets the same binary act as the child inspector for kittest harnesses, relaying bytes between the harness stdio and the MCP server's unix socket. - `server.rs`: rmcp stdio entry point. Live-app example added at `examples/egui_mcp/`.
This commit is contained in:
403
Cargo.lock
403
Cargo.lock
@@ -450,9 +450,9 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.83"
|
||||
version = "0.1.89"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd"
|
||||
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -621,7 +621,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "531a9155a481e2ee699d4f98f43c0ca4ff8ee1bfd55c31e9e98fb29d2b176fe0"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-automata 0.4.8",
|
||||
"serde",
|
||||
]
|
||||
|
||||
@@ -734,6 +734,18 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ciborium"
|
||||
version = "0.2.2"
|
||||
@@ -1087,6 +1099,40 @@ dependencies = [
|
||||
"env_logger",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
|
||||
dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-url"
|
||||
version = "0.3.1"
|
||||
@@ -1201,6 +1247,12 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f25c0e292a7ca6d6498557ff1df68f32c99850012b6ea401cf8daf771f22ff53"
|
||||
|
||||
[[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.34.2"
|
||||
@@ -1433,6 +1485,38 @@ dependencies = [
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_kittest_mcp"
|
||||
version = "0.34.2"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer",
|
||||
"anyhow",
|
||||
"base64",
|
||||
"egui",
|
||||
"egui_inspection",
|
||||
"egui_kittest",
|
||||
"image",
|
||||
"rmcp",
|
||||
"rmp-serde",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_mcp"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui_demo_lib",
|
||||
"egui_kittest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_tests"
|
||||
version = "0.34.2"
|
||||
@@ -1673,8 +1757,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f"
|
||||
dependencies = [
|
||||
"bit-set 0.8.0",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
"regex-automata 0.4.8",
|
||||
"regex-syntax 0.8.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1823,12 +1907,48 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.32"
|
||||
@@ -1859,6 +1979,12 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.32"
|
||||
@@ -1871,9 +1997,13 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
@@ -2217,6 +2347,30 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.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 = "icu_collections"
|
||||
version = "2.0.0"
|
||||
@@ -2303,6 +2457,12 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.0.3"
|
||||
@@ -2713,6 +2873,15 @@ version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e"
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558"
|
||||
dependencies = [
|
||||
"regex-automata 0.1.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.7.4"
|
||||
@@ -2874,6 +3043,16 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.46.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84"
|
||||
dependencies = [
|
||||
"overload",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.0"
|
||||
@@ -3293,6 +3472,12 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "overload"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
|
||||
|
||||
[[package]]
|
||||
name = "owned_ttf_parser"
|
||||
version = "0.25.0"
|
||||
@@ -3341,6 +3526,12 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -3889,6 +4080,26 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[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.11.1"
|
||||
@@ -3897,8 +4108,17 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
"regex-automata 0.4.8",
|
||||
"regex-syntax 0.8.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
|
||||
dependencies = [
|
||||
"regex-syntax 0.6.29",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3909,9 +4129,15 @@ checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
"regex-syntax 0.8.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.6.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.5"
|
||||
@@ -3988,6 +4214,41 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmcp"
|
||||
version = "1.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0810a9f717d9828f475fe1f629f4c305c8464b7f496c3a854b58d29e65f4058e"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
"chrono",
|
||||
"futures",
|
||||
"pastey",
|
||||
"pin-project-lite",
|
||||
"rmcp-macros",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmcp-macros"
|
||||
version = "1.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6aefac48c364756e97f04c0401ba3231e8607882c7c1d92da0437dc16307904d"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_json",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmp"
|
||||
version = "0.8.15"
|
||||
@@ -4148,6 +4409,32 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -4234,6 +4521,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.149"
|
||||
@@ -4276,6 +4574,15 @@ dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
@@ -4467,6 +4774,12 @@ dependencies = [
|
||||
"float-cmp",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@@ -4517,7 +4830,7 @@ dependencies = [
|
||||
"fnv",
|
||||
"once_cell",
|
||||
"plist",
|
||||
"regex-syntax",
|
||||
"regex-syntax 0.8.5",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
@@ -4637,6 +4950,15 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiff"
|
||||
version = "0.9.1"
|
||||
@@ -4746,13 +5068,40 @@ version = "1.49.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.0.6+spec-1.1.0"
|
||||
@@ -4825,6 +5174,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-log"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b"
|
||||
dependencies = [
|
||||
"matchers",
|
||||
"nu-ansi-term",
|
||||
"once_cell",
|
||||
"regex",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5058,6 +5437,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "vello_common"
|
||||
version = "0.0.8"
|
||||
|
||||
@@ -8,6 +8,7 @@ members = [
|
||||
"crates/egui_glow",
|
||||
"crates/egui_inspection",
|
||||
"crates/egui_kittest",
|
||||
"crates/egui_kittest_mcp",
|
||||
"crates/egui-wgpu",
|
||||
"crates/egui-winit",
|
||||
"crates/egui",
|
||||
|
||||
49
crates/egui_kittest_mcp/Cargo.toml
Normal file
49
crates/egui_kittest_mcp/Cargo.toml
Normal file
@@ -0,0 +1,49 @@
|
||||
[package]
|
||||
name = "egui_kittest_mcp"
|
||||
version.workspace = true
|
||||
authors = ["Lucas Meurer <hi@lucasmerlin.me>"]
|
||||
description = "MCP server that drives egui apps via the kittest inspector protocol"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
homepage = "https://github.com/emilk/egui"
|
||||
license.workspace = true
|
||||
repository = "https://github.com/emilk/egui"
|
||||
categories = ["gui", "development-tools::testing"]
|
||||
keywords = ["egui", "kittest", "mcp", "testing", "accesskit"]
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "kittest-mcp"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
egui_kittest = { workspace = true, features = ["inspector_api", "wgpu", "snapshot"] }
|
||||
egui_inspection = { workspace = true, features = ["protocol"] }
|
||||
egui.workspace = true
|
||||
accesskit.workspace = true
|
||||
accesskit_consumer.workspace = true
|
||||
image = { workspace = true, features = ["png"] }
|
||||
rmp-serde.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
schemars = "1.0"
|
||||
rmcp = { version = "1.7", features = ["server", "macros", "transport-io", "schemars"] }
|
||||
tempfile.workspace = true
|
||||
tokio = { version = "1.49", features = [
|
||||
"rt-multi-thread",
|
||||
"io-std",
|
||||
"io-util",
|
||||
"process",
|
||||
"net",
|
||||
"sync",
|
||||
"macros",
|
||||
"time",
|
||||
"signal",
|
||||
] }
|
||||
base64 = "0.22"
|
||||
anyhow = "1.0"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
435
crates/egui_kittest_mcp/src/bridge.rs
Normal file
435
crates/egui_kittest_mcp/src/bridge.rs
Normal file
@@ -0,0 +1,435 @@
|
||||
//! Bridge between the MCP server and a running kittest harness child process.
|
||||
//!
|
||||
//! Lifecycle:
|
||||
//! 1. [`Bridge::launch`] binds a unix domain socket, spawns the target binary with
|
||||
//! [`crate::HANDSHAKE_ENV_VAR`] + `KITTEST_INSPECTOR=1` +
|
||||
//! `KITTEST_INSPECTOR_PATH=<self>`, and waits for the shim to connect.
|
||||
//! 2. A reader task decodes [`HarnessMessage`]s from the socket and updates [`SharedState`].
|
||||
//! 3. A writer task drains [`InspectorCommand`]s queued by MCP tool handlers and writes
|
||||
//! them to the socket.
|
||||
//! 4. Tool handlers observe [`SharedState`] via [`Bridge::snapshot`] and wait for new
|
||||
//! frames or `Finished` via [`Bridge::wait_for_frame_after`].
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as _, anyhow, bail};
|
||||
use egui_inspection::protocol::{Frame, HarnessMessage, InspectorCommand, SourceView};
|
||||
use serde::Serialize;
|
||||
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
|
||||
use tokio::net::UnixListener;
|
||||
use tokio::process::{Child, Command};
|
||||
use tokio::sync::{Mutex, Notify, mpsc};
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::timeout;
|
||||
|
||||
/// Hard cap matching `inspector_api::MAX_MESSAGE_BYTES` so framing-level DoS is bounded.
|
||||
const MAX_MESSAGE_BYTES: usize = 256 * 1024 * 1024;
|
||||
|
||||
/// One in-flight peer (a spawned kittest harness or an attached live app) + the tasks
|
||||
/// that talk to it.
|
||||
pub struct Bridge {
|
||||
pub state: Arc<SharedState>,
|
||||
/// Outgoing command queue → writer task → socket.
|
||||
cmd_tx: mpsc::UnboundedSender<InspectorCommand>,
|
||||
/// Tokio task handles. Aborted on `Drop`; the child is killed too.
|
||||
_reader_task: JoinHandle<()>,
|
||||
_writer_task: JoinHandle<()>,
|
||||
/// `Child` wrapped in a `Mutex` so a `kill` tool can take it. `None` in attach mode —
|
||||
/// we don't own the lifecycle of an externally-started app.
|
||||
child: Arc<Mutex<Option<Child>>>,
|
||||
/// Temp dir holding the unix socket — kept alive while the bridge is.
|
||||
_socket_dir: tempfile::TempDir,
|
||||
/// How this bridge was created (informational).
|
||||
pub peer_info: PeerInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "mode", rename_all = "snake_case")]
|
||||
pub enum PeerInfo {
|
||||
/// Bridge spawned a child harness process.
|
||||
Launched {
|
||||
bin: PathBuf,
|
||||
args: Vec<String>,
|
||||
pid: u32,
|
||||
},
|
||||
/// Bridge bound a socket and accepted an incoming connection from a live app.
|
||||
Attached { socket_path: PathBuf },
|
||||
}
|
||||
|
||||
/// Mutable state observed by MCP tool handlers.
|
||||
///
|
||||
/// Guarded by a `Mutex` (not `RwLock`) because writers and readers contend on the same
|
||||
/// fields and acquire-cost is dominated by the rare `Frame` arrival, not lock contention.
|
||||
pub struct SharedState {
|
||||
inner: Mutex<Inner>,
|
||||
/// Notified whenever `inner` changes in a way a waiter might care about (new frame,
|
||||
/// blocked transition, finished). Coarse-grained on purpose.
|
||||
notify: Notify,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Inner {
|
||||
/// Set on receipt of [`HarnessMessage::Hello`]. `None` until the peer connects.
|
||||
pub hello: Option<egui_inspection::protocol::PeerHello>,
|
||||
pub latest_frame: Option<Box<Frame>>,
|
||||
pub blocked: bool,
|
||||
pub finished: Option<FinishedInfo>,
|
||||
/// Latest accesskit tree (re-built each time a `TreeUpdate` arrives).
|
||||
pub accesskit_tree: Option<accesskit_consumer::Tree>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct FinishedInfo {
|
||||
pub ok: bool,
|
||||
pub message: Option<String>,
|
||||
pub source: Option<SourceView>,
|
||||
}
|
||||
|
||||
/// Snapshot returned to tool handlers so they can drop the mutex before responding.
|
||||
#[derive(Clone)]
|
||||
pub struct StateSnapshot {
|
||||
/// Peer identity + capabilities, captured at connect time. Used by tool handlers to
|
||||
/// gate commands the peer doesn't honor (Step/Run/Pause against a live app, etc.).
|
||||
#[expect(dead_code, reason = "consumed by upcoming capability-gating in tool handlers")]
|
||||
pub hello: Option<egui_inspection::protocol::PeerHello>,
|
||||
pub frame: Option<Box<Frame>>,
|
||||
pub blocked: bool,
|
||||
pub finished: Option<FinishedInfo>,
|
||||
}
|
||||
|
||||
impl SharedState {
|
||||
fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
inner: Mutex::new(Inner::default()),
|
||||
notify: Notify::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn snapshot(&self) -> StateSnapshot {
|
||||
let g = self.inner.lock().await;
|
||||
StateSnapshot {
|
||||
hello: g.hello.clone(),
|
||||
frame: g.latest_frame.clone(),
|
||||
blocked: g.blocked,
|
||||
finished: g.finished.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only access to the accesskit tree via a closure. The tree isn't `Clone`, so
|
||||
/// callers project the data they need (node list, lookup by id) before returning.
|
||||
pub async fn with_tree<R>(
|
||||
&self,
|
||||
f: impl FnOnce(Option<&accesskit_consumer::Tree>) -> R,
|
||||
) -> R {
|
||||
let g = self.inner.lock().await;
|
||||
f(g.accesskit_tree.as_ref())
|
||||
}
|
||||
|
||||
/// Await the next state-change notification. Used by tools that poll (e.g. `wait_for`)
|
||||
/// to wake on a new frame / blocked transition without busy-looping.
|
||||
pub async fn notified(&self) {
|
||||
self.notify.notified().await;
|
||||
}
|
||||
}
|
||||
|
||||
impl Bridge {
|
||||
pub async fn launch(
|
||||
bin: PathBuf,
|
||||
args: Vec<String>,
|
||||
env: Vec<(String, String)>,
|
||||
cwd: Option<PathBuf>,
|
||||
) -> anyhow::Result<Self> {
|
||||
let self_path = std::env::current_exe()
|
||||
.context("get current_exe for KITTEST_INSPECTOR_PATH")?;
|
||||
|
||||
let socket_dir = tempfile::Builder::new()
|
||||
.prefix("kittest-mcp-")
|
||||
.tempdir()
|
||||
.context("create temp dir for handshake socket")?;
|
||||
let socket_path = socket_dir.path().join("kittest.sock");
|
||||
|
||||
let listener = UnixListener::bind(&socket_path)
|
||||
.with_context(|| format!("bind {}", socket_path.display()))?;
|
||||
|
||||
let mut cmd = Command::new(&bin);
|
||||
cmd.args(&args)
|
||||
.env("KITTEST_INSPECTOR", "1")
|
||||
.env("KITTEST_INSPECTOR_PATH", &self_path)
|
||||
.env(crate::HANDSHAKE_ENV_VAR, &socket_path)
|
||||
.stdin(std::process::Stdio::null())
|
||||
// Harness inspector path: the child's stdout/stderr aren't ours — they get
|
||||
// captured by the shim. We don't need them in the MCP server.
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.kill_on_drop(true);
|
||||
for (k, v) in &env {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
if let Some(d) = &cwd {
|
||||
cmd.current_dir(d);
|
||||
}
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.with_context(|| format!("spawn {}", bin.display()))?;
|
||||
let pid = child.id().unwrap_or(0);
|
||||
|
||||
// Accept with a short timeout. If the binary fails to start, exits early, or
|
||||
// doesn't have the inspector wired up, we surface that instead of hanging forever.
|
||||
let (stream, _addr) = match timeout(Duration::from_secs(10), listener.accept()).await {
|
||||
Ok(Ok(pair)) => pair,
|
||||
Ok(Err(e)) => {
|
||||
let _ = child.kill().await;
|
||||
bail!("accept on handshake socket: {e}");
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = child.kill().await;
|
||||
// Try to report the child's exit status if it died early.
|
||||
let status_hint = match child.try_wait() {
|
||||
Ok(Some(s)) => format!(" (child exited {s})"),
|
||||
_ => String::new(),
|
||||
};
|
||||
bail!("timed out waiting for inspector handshake{status_hint}");
|
||||
}
|
||||
};
|
||||
|
||||
let (reader, writer) = stream.into_split();
|
||||
let state = SharedState::new();
|
||||
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
|
||||
let child_arc: Arc<Mutex<Option<Child>>> = Arc::new(Mutex::new(Some(child)));
|
||||
|
||||
let reader_task = tokio::spawn(read_loop(reader, state.clone(), child_arc.clone()));
|
||||
let writer_task = tokio::spawn(write_loop(writer, cmd_rx));
|
||||
|
||||
Ok(Self {
|
||||
state,
|
||||
cmd_tx,
|
||||
_reader_task: reader_task,
|
||||
_writer_task: writer_task,
|
||||
child: child_arc,
|
||||
_socket_dir: socket_dir,
|
||||
peer_info: PeerInfo::Launched { bin, args, pid },
|
||||
})
|
||||
}
|
||||
|
||||
/// Bind a unix socket and return the path immediately. The caller is responsible for
|
||||
/// starting the app with `EGUI_INSPECTION_SOCKET` set to this path. Call
|
||||
/// [`Self::accept_pending`] once the app is running.
|
||||
///
|
||||
/// Returns the temp-dir handle (must be kept alive) and the listener.
|
||||
pub async fn prepare_attach() -> anyhow::Result<(tempfile::TempDir, UnixListener, PathBuf)> {
|
||||
let socket_dir = tempfile::Builder::new()
|
||||
.prefix("egui-inspection-")
|
||||
.tempdir()
|
||||
.context("create temp dir for inspection socket")?;
|
||||
let socket_path = socket_dir.path().join("inspection.sock");
|
||||
let listener = UnixListener::bind(&socket_path)
|
||||
.with_context(|| format!("bind {}", socket_path.display()))?;
|
||||
Ok((socket_dir, listener, socket_path))
|
||||
}
|
||||
|
||||
/// Finish an attach started with [`Self::prepare_attach`] — wait for an inbound
|
||||
/// connection and spawn the reader/writer tasks.
|
||||
///
|
||||
/// `child` is the optional child process that was spawned with the socket env var
|
||||
/// pre-set. Passing it here lets `kill` reach it and `kill_on_drop` clean up if the
|
||||
/// bridge is dropped.
|
||||
pub async fn accept_pending(
|
||||
socket_dir: tempfile::TempDir,
|
||||
listener: UnixListener,
|
||||
socket_path: PathBuf,
|
||||
child: Option<Child>,
|
||||
accept_timeout: Duration,
|
||||
) -> anyhow::Result<Self> {
|
||||
let (stream, _addr) = match timeout(accept_timeout, listener.accept()).await {
|
||||
Ok(Ok(pair)) => pair,
|
||||
Ok(Err(e)) => bail!("accept on inspection socket: {e}"),
|
||||
Err(_) => bail!("timed out waiting for inbound connection at {}", socket_path.display()),
|
||||
};
|
||||
|
||||
let (reader, writer) = stream.into_split();
|
||||
let state = SharedState::new();
|
||||
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
|
||||
let child_arc: Arc<Mutex<Option<Child>>> = Arc::new(Mutex::new(child));
|
||||
let reader_task = tokio::spawn(read_loop(reader, state.clone(), child_arc.clone()));
|
||||
let writer_task = tokio::spawn(write_loop(writer, cmd_rx));
|
||||
|
||||
Ok(Self {
|
||||
state,
|
||||
cmd_tx,
|
||||
_reader_task: reader_task,
|
||||
_writer_task: writer_task,
|
||||
child: child_arc,
|
||||
_socket_dir: socket_dir,
|
||||
peer_info: PeerInfo::Attached { socket_path },
|
||||
})
|
||||
}
|
||||
|
||||
pub fn send(&self, cmd: InspectorCommand) -> anyhow::Result<()> {
|
||||
self.cmd_tx
|
||||
.send(cmd)
|
||||
.map_err(|_| anyhow!("inspector writer task is gone"))
|
||||
}
|
||||
|
||||
/// Wait for either a new frame whose `step > prev_step`, or a `Finished` signal,
|
||||
/// whichever comes first. Returns the resulting snapshot or times out.
|
||||
pub async fn wait_for_frame_after(
|
||||
&self,
|
||||
prev_step: u64,
|
||||
wait: Duration,
|
||||
) -> anyhow::Result<StateSnapshot> {
|
||||
let deadline = tokio::time::Instant::now() + wait;
|
||||
loop {
|
||||
let snap = self.state.snapshot().await;
|
||||
if snap.finished.is_some() {
|
||||
return Ok(snap);
|
||||
}
|
||||
if let Some(f) = &snap.frame {
|
||||
if f.step > prev_step {
|
||||
return Ok(snap);
|
||||
}
|
||||
}
|
||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
bail!("timed out waiting for next frame after step {prev_step}");
|
||||
}
|
||||
let _ = timeout(remaining, self.state.notify.notified()).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn kill(&self) {
|
||||
if let Some(mut c) = self.child.lock().await.take() {
|
||||
let _ = c.kill().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Bridge {
|
||||
fn drop(&mut self) {
|
||||
// Best-effort: ensure the child is reaped. `kill_on_drop(true)` on `Command` also
|
||||
// guarantees this, but we set it explicitly for the case where someone replaces the
|
||||
// `Child` and forgets the flag.
|
||||
if let Ok(mut g) = self.child.try_lock() {
|
||||
if let Some(mut c) = g.take() {
|
||||
let _ = c.start_kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_loop(
|
||||
mut reader: tokio::net::unix::OwnedReadHalf,
|
||||
state: Arc<SharedState>,
|
||||
child: Arc<Mutex<Option<Child>>>,
|
||||
) {
|
||||
loop {
|
||||
let msg = match read_message(&mut reader).await {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::debug!("inspector socket read ended: {e}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
apply_message(&state, msg).await;
|
||||
}
|
||||
// Reader ended → harness is gone. Make sure we eventually reap the child.
|
||||
if let Some(mut c) = child.lock().await.take() {
|
||||
let _ = c.kill().await;
|
||||
}
|
||||
// Wake any waiter so they can observe disconnection.
|
||||
state.notify.notify_waiters();
|
||||
}
|
||||
|
||||
async fn apply_message(state: &SharedState, msg: HarnessMessage) {
|
||||
let mut g = state.inner.lock().await;
|
||||
match msg {
|
||||
HarnessMessage::Hello(hello) => {
|
||||
g.hello = Some(hello);
|
||||
}
|
||||
HarnessMessage::Frame(frame) => {
|
||||
if let Some(update) = &frame.accesskit {
|
||||
let mut noop = NoopChangeHandler;
|
||||
match g.accesskit_tree.as_mut() {
|
||||
Some(tree) => tree.update_and_process_changes(update.clone(), &mut noop),
|
||||
None => {
|
||||
g.accesskit_tree =
|
||||
Some(accesskit_consumer::Tree::new(update.clone(), false));
|
||||
}
|
||||
}
|
||||
}
|
||||
g.latest_frame = Some(frame);
|
||||
}
|
||||
HarnessMessage::Blocked(b) => g.blocked = b,
|
||||
HarnessMessage::Finished {
|
||||
ok,
|
||||
message,
|
||||
source,
|
||||
} => {
|
||||
g.finished = Some(FinishedInfo {
|
||||
ok,
|
||||
message,
|
||||
source,
|
||||
});
|
||||
g.blocked = true;
|
||||
}
|
||||
}
|
||||
drop(g);
|
||||
state.notify.notify_waiters();
|
||||
}
|
||||
|
||||
struct NoopChangeHandler;
|
||||
|
||||
impl accesskit_consumer::TreeChangeHandler for NoopChangeHandler {
|
||||
fn node_added(&mut self, _: &accesskit_consumer::Node<'_>) {}
|
||||
fn node_updated(
|
||||
&mut self,
|
||||
_: &accesskit_consumer::Node<'_>,
|
||||
_: &accesskit_consumer::Node<'_>,
|
||||
) {
|
||||
}
|
||||
fn focus_moved(
|
||||
&mut self,
|
||||
_: Option<&accesskit_consumer::Node<'_>>,
|
||||
_: Option<&accesskit_consumer::Node<'_>>,
|
||||
) {
|
||||
}
|
||||
fn node_removed(&mut self, _: &accesskit_consumer::Node<'_>) {}
|
||||
}
|
||||
|
||||
async fn write_loop(
|
||||
mut writer: tokio::net::unix::OwnedWriteHalf,
|
||||
mut rx: mpsc::UnboundedReceiver<InspectorCommand>,
|
||||
) {
|
||||
while let Some(cmd) = rx.recv().await {
|
||||
if let Err(e) = write_message(&mut writer, &cmd).await {
|
||||
tracing::debug!("inspector socket write ended: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_message(stream: &mut tokio::net::unix::OwnedReadHalf) -> anyhow::Result<HarnessMessage> {
|
||||
let mut len_buf = [0u8; 4];
|
||||
stream.read_exact(&mut len_buf).await?;
|
||||
let len = u32::from_be_bytes(len_buf) as usize;
|
||||
if len > MAX_MESSAGE_BYTES {
|
||||
bail!("message too large: {len} bytes");
|
||||
}
|
||||
let mut buf = vec![0u8; len];
|
||||
stream.read_exact(&mut buf).await?;
|
||||
rmp_serde::from_slice(&buf).map_err(|e| anyhow!("decode: {e}"))
|
||||
}
|
||||
|
||||
async fn write_message(
|
||||
stream: &mut tokio::net::unix::OwnedWriteHalf,
|
||||
msg: &InspectorCommand,
|
||||
) -> anyhow::Result<()> {
|
||||
let bytes = rmp_serde::to_vec(msg).map_err(|e| anyhow!("encode: {e}"))?;
|
||||
let len = u32::try_from(bytes.len())?;
|
||||
stream.write_all(&len.to_be_bytes()).await?;
|
||||
stream.write_all(&bytes).await?;
|
||||
stream.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
45
crates/egui_kittest_mcp/src/main.rs
Normal file
45
crates/egui_kittest_mcp/src/main.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
//! `kittest-mcp` — dual-role binary.
|
||||
//!
|
||||
//! Default role: **MCP server**. Speaks MCP JSON-RPC over stdio to an agent. Exposes a
|
||||
//! `launch` tool that spawns a target egui kittest binary with the inspector protocol
|
||||
//! pointed back at this same executable in shim mode.
|
||||
//!
|
||||
//! Shim role: activated when [`HANDSHAKE_ENV_VAR`] is set. The target binary's
|
||||
//! [`egui_kittest::InspectorPlugin`] thinks it's talking to the regular `kittest_inspector`
|
||||
//! over stdio; in reality it's talking to us, and we relay the bytes to the MCP server
|
||||
//! over a unix domain socket.
|
||||
|
||||
mod bridge;
|
||||
mod server;
|
||||
mod shim;
|
||||
mod tools;
|
||||
mod tree;
|
||||
|
||||
/// Env var carrying the unix socket path the shim should connect to.
|
||||
pub const HANDSHAKE_ENV_VAR: &str = "KITTEST_MCP_HANDSHAKE";
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
if let Ok(socket_path) = std::env::var(HANDSHAKE_ENV_VAR) {
|
||||
// Shim role: relay bytes between harness stdio and the MCP server's socket.
|
||||
// No tokio runtime — keep the dependency surface tiny and the relay deterministic.
|
||||
shim::run(&socket_path)
|
||||
} else {
|
||||
// Server role: MCP over stdio.
|
||||
init_tracing();
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
rt.block_on(server::run())
|
||||
}
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
use tracing_subscriber::EnvFilter;
|
||||
let filter = EnvFilter::try_from_env("KITTEST_MCP_LOG")
|
||||
.unwrap_or_else(|_| EnvFilter::new("kittest_mcp=info,warn"));
|
||||
// stderr only — stdout is reserved for MCP JSON-RPC traffic.
|
||||
let _ = tracing_subscriber::fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_writer(std::io::stderr)
|
||||
.try_init();
|
||||
}
|
||||
16
crates/egui_kittest_mcp/src/server.rs
Normal file
16
crates/egui_kittest_mcp/src/server.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
//! MCP server entry point, built on the official `rmcp` SDK over stdio.
|
||||
//!
|
||||
//! [`run`] constructs a [`crate::tools::Server`] (which derives its tool router via
|
||||
//! `#[tool_router]`) and serves it on `(stdin, stdout)`. Returns once the client closes
|
||||
//! the connection (EOF on stdin) or the runtime is shut down.
|
||||
|
||||
use rmcp::{ServiceExt, transport};
|
||||
|
||||
use crate::tools::Server;
|
||||
|
||||
pub async fn run() -> anyhow::Result<()> {
|
||||
let server = Server::new();
|
||||
let running = server.serve(transport::stdio()).await?;
|
||||
let _reason = running.waiting().await?;
|
||||
Ok(())
|
||||
}
|
||||
50
crates/egui_kittest_mcp/src/shim.rs
Normal file
50
crates/egui_kittest_mcp/src/shim.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
//! Inspector shim role.
|
||||
//!
|
||||
//! Connects to the MCP server's unix domain socket and relays bytes in both directions
|
||||
//! between the harness's stdio and that socket.
|
||||
//!
|
||||
//! From the harness's perspective we're an ordinary `kittest_inspector` (msgpack framed
|
||||
//! messages on stdin/stdout). The MCP server sees the same framed bytes on the other end of
|
||||
//! the socket. We don't parse or interpret anything here — pure byte relay keeps the shim
|
||||
//! independent of protocol revisions.
|
||||
|
||||
use std::io::{Read as _, Write as _};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::thread;
|
||||
|
||||
pub fn run(socket_path: &str) -> anyhow::Result<()> {
|
||||
let stream = UnixStream::connect(socket_path)
|
||||
.map_err(|e| anyhow::anyhow!("connect {socket_path}: {e}"))?;
|
||||
let stream_to_stdout = stream.try_clone()?;
|
||||
let mut stdin_to_socket = stream;
|
||||
|
||||
// Thread A: stdin (from harness) → socket (to MCP server).
|
||||
let t_in = thread::Builder::new()
|
||||
.name("kittest-mcp-shim-stdin".into())
|
||||
.spawn(move || {
|
||||
let mut stdin = std::io::stdin().lock();
|
||||
let _ = std::io::copy(&mut stdin, &mut stdin_to_socket);
|
||||
// EOF on stdin or write error → shutdown write side so peer sees EOF.
|
||||
let _ = stdin_to_socket.shutdown(std::net::Shutdown::Write);
|
||||
})?;
|
||||
|
||||
// Thread B: socket (from MCP server) → stdout (to harness).
|
||||
// Runs on main thread so the process exits when stdout closes.
|
||||
let mut stdout = std::io::stdout().lock();
|
||||
let mut buf = vec![0u8; 64 * 1024];
|
||||
let mut reader = stream_to_stdout;
|
||||
loop {
|
||||
match reader.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
if stdout.write_all(&buf[..n]).is_err() {
|
||||
break;
|
||||
}
|
||||
let _ = stdout.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = t_in.join();
|
||||
Ok(())
|
||||
}
|
||||
1313
crates/egui_kittest_mcp/src/tools.rs
Normal file
1313
crates/egui_kittest_mcp/src/tools.rs
Normal file
File diff suppressed because it is too large
Load Diff
206
crates/egui_kittest_mcp/src/tree.rs
Normal file
206
crates/egui_kittest_mcp/src/tree.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
//! Helpers that flatten the accesskit tree into MCP-friendly shapes.
|
||||
//!
|
||||
//! Note: `accesskit_consumer::NodeId` is a private composite (tree-index + local-id) and
|
||||
//! can't be constructed from outside the crate. We project everything externally as the
|
||||
//! original `accesskit::NodeId` (a `pub u64`), and look up by walking the tree.
|
||||
|
||||
use accesskit_consumer::{Node, Tree};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
pub struct NodeView {
|
||||
/// Original `accesskit::NodeId` as a decimal string. Emitted as a string so the full
|
||||
/// u64 round-trips through MCP clients whose JSON parsers go through `f64` (which
|
||||
/// can't represent integers above 2^53 exactly).
|
||||
pub id: String,
|
||||
pub role: String,
|
||||
pub label: Option<String>,
|
||||
pub value: Option<String>,
|
||||
pub bounds: Option<RectF>,
|
||||
pub focused: bool,
|
||||
pub disabled: bool,
|
||||
pub hidden: bool,
|
||||
pub parent_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, JsonSchema)]
|
||||
pub struct RectF {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub w: f64,
|
||||
pub h: f64,
|
||||
}
|
||||
|
||||
impl RectF {
|
||||
fn from_rect(r: accesskit::Rect) -> Self {
|
||||
Self {
|
||||
x: r.x0,
|
||||
y: r.y0,
|
||||
w: r.x1 - r.x0,
|
||||
h: r.y1 - r.y0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn center(&self) -> (f64, f64) {
|
||||
(self.x + self.w / 2.0, self.y + self.h / 2.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
||||
pub struct QueryFilter {
|
||||
pub role: Option<String>,
|
||||
pub label_contains: Option<String>,
|
||||
#[serde(default = "default_true")]
|
||||
pub visible_only: bool,
|
||||
#[serde(default = "default_limit")]
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_limit() -> usize {
|
||||
200
|
||||
}
|
||||
|
||||
impl Default for QueryFilter {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
role: None,
|
||||
label_contains: None,
|
||||
visible_only: true,
|
||||
limit: default_limit(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn query(tree: &Tree, filter: &QueryFilter) -> Vec<NodeView> {
|
||||
let root = tree.state().root();
|
||||
let mut out = Vec::new();
|
||||
walk(&root, filter, &mut out);
|
||||
if out.len() > filter.limit {
|
||||
out.truncate(filter.limit);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn walk(node: &Node<'_>, filter: &QueryFilter, out: &mut Vec<NodeView>) {
|
||||
if matches(node, filter) {
|
||||
out.push(node_view(node));
|
||||
}
|
||||
for child in node.children() {
|
||||
walk(&child, filter, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn matches(node: &Node<'_>, filter: &QueryFilter) -> bool {
|
||||
if filter.visible_only && node.is_hidden() {
|
||||
return false;
|
||||
}
|
||||
if let Some(role) = &filter.role
|
||||
&& !role.eq_ignore_ascii_case(&format!("{:?}", node.role()))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(needle) = &filter.label_contains {
|
||||
let hay = node.label().unwrap_or_default();
|
||||
if !hay
|
||||
.to_ascii_lowercase()
|
||||
.contains(&needle.to_ascii_lowercase())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn node_view(node: &Node<'_>) -> NodeView {
|
||||
NodeView {
|
||||
id: accesskit_id(node).to_string(),
|
||||
role: format!("{:?}", node.role()),
|
||||
label: node.label(),
|
||||
value: node.value(),
|
||||
bounds: node.bounding_box().map(RectF::from_rect),
|
||||
focused: node.is_focused_in_tree(),
|
||||
disabled: node.is_disabled(),
|
||||
hidden: node.is_hidden(),
|
||||
parent_id: node.parent().map(|p| accesskit_id(&p).to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Project a consumer node to its original `accesskit::NodeId` as a `u64`.
|
||||
fn accesskit_id(node: &Node<'_>) -> u64 {
|
||||
let (local, _tree) = node.locate();
|
||||
local.0
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum Locator {
|
||||
Id {
|
||||
/// Decimal string. Strings preserve the full u64 — JSON numbers above 2^53 lose
|
||||
/// precision in clients whose parsers go through `f64`, so we don't accept them.
|
||||
#[serde(deserialize_with = "deserialize_u64_from_string")]
|
||||
id: u64,
|
||||
},
|
||||
Match {
|
||||
#[serde(default)]
|
||||
role: Option<String>,
|
||||
#[serde(default)]
|
||||
label_contains: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn deserialize_u64_from_string<'de, D>(d: D) -> Result<u64, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::de::Error as _;
|
||||
let s = String::deserialize(d)?;
|
||||
s.trim().parse::<u64>().map_err(D::Error::custom)
|
||||
}
|
||||
|
||||
pub fn resolve_node<'a>(tree: &'a Tree, locator: &Locator) -> Option<Node<'a>> {
|
||||
match locator {
|
||||
Locator::Id { id } => find_by_id(&tree.state().root(), *id),
|
||||
Locator::Match {
|
||||
role,
|
||||
label_contains,
|
||||
} => {
|
||||
let filter = QueryFilter {
|
||||
role: role.clone(),
|
||||
label_contains: label_contains.clone(),
|
||||
visible_only: true,
|
||||
limit: 1,
|
||||
};
|
||||
let root = tree.state().root();
|
||||
first_match(&root, &filter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_by_id<'a>(node: &Node<'a>, target: u64) -> Option<Node<'a>> {
|
||||
if accesskit_id(node) == target {
|
||||
return Some(*node);
|
||||
}
|
||||
for child in node.children() {
|
||||
if let Some(found) = find_by_id(&child, target) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn first_match<'a>(node: &Node<'a>, filter: &QueryFilter) -> Option<Node<'a>> {
|
||||
if matches(node, filter) {
|
||||
return Some(*node);
|
||||
}
|
||||
for child in node.children() {
|
||||
if let Some(found) = first_match(&child, filter) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
17
examples/egui_mcp/Cargo.toml
Normal file
17
examples/egui_mcp/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "egui_mcp"
|
||||
version = "0.1.0"
|
||||
authors = ["Lucas Meurer <hi@lucasmerlin.me>"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.92"
|
||||
publish = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
|
||||
[dependencies]
|
||||
egui = { workspace = true, features = ["default"] }
|
||||
egui_demo_lib = { workspace = true, features = ["default"] }
|
||||
egui_kittest = { workspace = true, features = ["wgpu", "inspector"] }
|
||||
27
examples/egui_mcp/src/main.rs
Normal file
27
examples/egui_mcp/src/main.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
//! Headless `egui_demo_lib` target for the kittest MCP server.
|
||||
//!
|
||||
//! Build a [`Harness`] around [`DemoWindows`] and step forever. The
|
||||
//! [`egui_kittest::InspectorPlugin`] auto-attaches whenever `KITTEST_INSPECTOR` is set
|
||||
//! (the MCP server's `launch` tool sets it), drives the harness via stdio, and blocks
|
||||
//! inside `after_step` until the agent requests the next frame.
|
||||
|
||||
#![expect(rustdoc::missing_crate_level_docs)]
|
||||
|
||||
use egui::Vec2;
|
||||
use egui_demo_lib::DemoWindows;
|
||||
use egui_kittest::Harness;
|
||||
|
||||
fn main() {
|
||||
let mut demo = DemoWindows::default();
|
||||
|
||||
let mut harness = Harness::builder()
|
||||
.with_size(Vec2::new(1024.0, 768.0))
|
||||
.wgpu()
|
||||
.build_ui(move |ui| {
|
||||
demo.ui(ui);
|
||||
});
|
||||
|
||||
loop {
|
||||
harness.step();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user