diff --git a/.gitignore b/.gitignore index 9db3ba3..1343ed5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ .idea/ .claude/ .qoder/ -.mcdev.json \ No newline at end of file +.mcdev.json +.emod-cli/ \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 5fa1799..413c643 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,6 +28,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.18" @@ -86,6 +95,23 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "base64" version = "0.22.1" @@ -113,6 +139,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + [[package]] name = "bzip2" version = "0.4.4" @@ -151,6 +183,20 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "cipher" version = "0.4.4" @@ -213,6 +259,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.16" @@ -262,6 +314,40 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "deflate64" version = "0.1.9" @@ -310,6 +396,12 @@ dependencies = [ "syn", ] +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "emod-cli" version = "0.1.0-dev" @@ -318,8 +410,10 @@ dependencies = [ "clap", "rand", "regex", + "rmcp", "serde", "serde_json", + "tokio", "toml", "uuid", "walkdir", @@ -343,6 +437,94 @@ dependencies = [ "miniz_oxide", ] +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +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", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -385,6 +567,36 @@ dependencies = [ "digest", ] +[[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 = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "indexmap" version = "2.7.0" @@ -425,6 +637,17 @@ dependencies = [ "libc", ] +[[package]] +name = "js-sys" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.169" @@ -474,12 +697,27 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pbkdf2" version = "0.12.2" @@ -490,6 +728,12 @@ dependencies = [ "hmac", ] +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "pkg-config" version = "0.3.31" @@ -559,6 +803,26 @@ dependencies = [ "getrandom", ] +[[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" @@ -588,6 +852,47 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "rmcp" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4c9c94680f75470ee8083a0667988b5d7b5beb70b9f998a8e51de7c682ce60" +dependencies = [ + "async-trait", + "base64", + "chrono", + "futures", + "pastey", + "pin-project-lite", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90c23c8f26cae4da838fbc3eadfaecf2d549d97c04b558e7bd90526a9c28b42a" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "ryu" version = "1.0.18" @@ -603,6 +908,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 = "serde" version = "1.0.217" @@ -623,6 +954,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.134" @@ -667,6 +1009,12 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "strsim" version = "0.11.1" @@ -729,6 +1077,41 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "pin-project-lite", + "tokio-macros", +] + +[[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-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 = "0.8.23" @@ -770,6 +1153,37 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "typenum" version = "1.17.0" @@ -832,6 +1246,51 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasm-bindgen" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" +dependencies = [ + "unicode-ident", +] + [[package]] name = "winapi-util" version = "0.1.9" @@ -841,6 +1300,65 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.59.0" diff --git a/Cargo.toml b/Cargo.toml index 3d1a5d4..1f9dfe1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,8 @@ clap = { version = "4.5.32", features = ["derive"] } uuid = { version = "1.4", features = ["v4", "fast-rng", "macro-diagnostics"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +rmcp = { version = "0.16.0", features = ["server", "macros", "transport-io"] } +tokio = { version = "1", features = ["macros", "rt", "io-std", "time"] } base64 = "0.22" toml = "0.8" zip = "2.2.2" diff --git a/README.md b/README.md index 5fb1dc2..f6f1f82 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,25 @@ emod-cli debug --new # 创建全新调试存档 2. 清理运行时旧包链接 3. 注册调试 MOD 并链接用户 MOD 目录 4. 准备开发世界(含自动加入游戏配置) -5. 启动游戏进程,挂载 IPC 日志与热重载 +5. 启动游戏进程;启用内置调试 MOD 时挂载 IPC 日志、热重载与 debug MCP bridge + +### mcp + +启动标准 stdio MCP server,让 AI 连接正在运行的 `debug` 会话并在游戏内放置方块。`mcp` 命令不会启动游戏;必须先为同一项目启动启用了内置调试 MOD 的 `debug`。 + +```bash +# 终端 1:启动游戏并注入 debug MCP bridge +emod-cli debug --path <项目路径> + +# MCP 客户端配置:启动 stdio MCP server +emod-cli mcp --path <项目路径> +``` + +工具示例: + +```text +place_block {"x":0,"y":5,"z":0,"name":"minecraft:stone"} +``` ### release diff --git a/debug_mod/DEBUG_ENV_SCRIPT/Config.py b/debug_mod/DEBUG_ENV_SCRIPT/Config.py index a9dfe80..eab3f16 100644 --- a/debug_mod/DEBUG_ENV_SCRIPT/Config.py +++ b/debug_mod/DEBUG_ENV_SCRIPT/Config.py @@ -18,4 +18,11 @@ def GET_DEBUG_IPC_PORT(): port = os.getenv("MCDEV_DEBUG_IPC_PORT") if port is None: return None - return int(port) \ No newline at end of file + return int(port) + +def GET_MCP_BRIDGE_STATE_PATH(): + import os + path = os.getenv("MCDEV_MCP_BRIDGE_STATE") + if not path: + return None + return path \ No newline at end of file diff --git a/debug_mod/DEBUG_ENV_SCRIPT/DebugServerSystem.py b/debug_mod/DEBUG_ENV_SCRIPT/DebugServerSystem.py index 63ecd2b..43c7d68 100644 --- a/debug_mod/DEBUG_ENV_SCRIPT/DebugServerSystem.py +++ b/debug_mod/DEBUG_ENV_SCRIPT/DebugServerSystem.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- import mod.server.extraServerApi as serverApi -from . import IPCSystem, Lifecycle +from . import IPCSystem, Lifecycle, MCPBridge ServerSystem = serverApi.GetServerSystemCls() @@ -16,13 +16,18 @@ class DebugServerSystem(ServerSystem): if not self._initialized: Lifecycle.activate(self) IPCSystem.ON_SERVER_INIT() + MCPBridge.ON_SERVER_INIT() self._initialized = True IPCSystem.UPDATE_SERVER() + MCPBridge.UPDATE() return ServerSystem.Update(self) def Destroy(self): try: - IPCSystem.ON_SERVER_EXIT() + MCPBridge.ON_SERVER_EXIT() finally: - self._initialized = False - Lifecycle.deactivate(self) + try: + IPCSystem.ON_SERVER_EXIT() + finally: + self._initialized = False + Lifecycle.deactivate(self) diff --git a/debug_mod/DEBUG_ENV_SCRIPT/MCPBridge.py b/debug_mod/DEBUG_ENV_SCRIPT/MCPBridge.py new file mode 100644 index 0000000..87da286 --- /dev/null +++ b/debug_mod/DEBUG_ENV_SCRIPT/MCPBridge.py @@ -0,0 +1,438 @@ +# -*- coding: utf-8 -*- +import json +import os +import socket +import threading +import traceback + +import mod.server.extraServerApi as serverApi + +from .Config import GET_MCP_BRIDGE_STATE_PATH + +try: + basestring +except NameError: + basestring = str + +try: + integer_types = (int, long) +except NameError: + integer_types = (int,) + +_BRIDGE = None +_SERVER_ACTIVE = False +_SERVER_TASKS = [] +_SERVER_TASKS_LOCK = threading.Lock() + +MAX_PENDING_TASKS = 32 +MAX_REQUEST_BYTES = 1024 * 1024 + + +class BridgeError(Exception): + pass + + +def _is_int(value): + return isinstance(value, integer_types) and not isinstance(value, bool) + + +def _event_is_set(event): + if hasattr(event, "is_set"): + return event.is_set() + return event.isSet() + + +def _create_daemon_thread(target, args=()): + thread = threading.Thread(target=target, args=args) + try: + thread.daemon = True + except Exception: + thread.setDaemon(True) + return thread + + +def _to_text(data): + if isinstance(data, bytes): + return data.decode("utf-8") + return data + + +def _send_json_line(sock, payload): + data = json.dumps(payload, separators=(",", ":")) + "\n" + if not isinstance(data, bytes): + data = data.encode("utf-8") + sock.sendall(data) + + +def _require_params(params): + if not isinstance(params, dict): + raise BridgeError("place_block params must be an object") + + for key in ("x", "y", "z"): + if key not in params or not _is_int(params.get(key)): + raise BridgeError("place_block.%s must be an integer" % key) + + name = params.get("name") + if not isinstance(name, basestring) or not name.strip() or ":" not in name: + raise BridgeError("place_block.name must be a non-empty namespaced block id") + + dimension = params.get("dimension", 0) + if not _is_int(dimension): + raise BridgeError("place_block.dimension must be an integer") + + old_block_handling = params.get("old_block_handling", 0) + if not _is_int(old_block_handling) or old_block_handling not in (0, 1, 2): + raise BridgeError("place_block.old_block_handling must be 0, 1, or 2") + + aux = params.get("aux", None) + states = params.get("states", None) + if aux is not None and states is not None: + raise BridgeError("place_block.aux and place_block.states cannot both be provided") + if aux is not None and not _is_int(aux): + raise BridgeError("place_block.aux must be an integer") + if states is not None and not isinstance(states, dict): + raise BridgeError("place_block.states must be an object") + + is_legacy = params.get("is_legacy", True) + if not isinstance(is_legacy, bool): + raise BridgeError("place_block.is_legacy must be a boolean") + + update_neighbors = params.get("update_neighbors", True) + if not isinstance(update_neighbors, bool): + raise BridgeError("place_block.update_neighbors must be a boolean") + + return { + "x": params["x"], + "y": params["y"], + "z": params["z"], + "name": name, + "dimension": dimension, + "aux": aux, + "states": states, + "old_block_handling": old_block_handling, + "is_legacy": is_legacy, + "update_neighbors": update_neighbors, + } + + +def _place_block_on_server(request): + name = request["name"] + aux = request["aux"] + states = request["states"] + if states is not None: + block_state = serverApi.GetEngineCompFactory().CreateBlockState(serverApi.GetLevelId()) + aux = block_state.GetBlockAuxValueFromStates(name, states) + if aux == -1: + raise BridgeError("invalid block states for %s" % name) + elif aux is None: + aux = 0 + + x = request["x"] + y = request["y"] + z = request["z"] + dimension = request["dimension"] + old_block_handling = request["old_block_handling"] + is_legacy = request["is_legacy"] + update_neighbors = request["update_neighbors"] + + block_info = serverApi.GetEngineCompFactory().CreateBlockInfo(serverApi.GetLevelId()) + changed = block_info.SetBlockNew( + (x, y, z), + {"name": name, "aux": aux}, + old_block_handling, + dimension, + is_legacy, + update_neighbors, + ) + return { + "changed": bool(changed), + "position": [x, y, z], + "dimension": dimension, + "block": {"name": name, "aux": aux}, + "old_block_handling": old_block_handling, + "is_legacy": is_legacy, + "update_neighbors": update_neighbors, + } + + +def _place_block(params): + request = _require_params(params) + task = { + "request": request, + "done": threading.Event(), + "cancelled": False, + "started": False, + } + with _SERVER_TASKS_LOCK: + if not _SERVER_ACTIVE: + raise BridgeError("server system is not ready") + if len(_SERVER_TASKS) >= MAX_PENDING_TASKS: + raise BridgeError("server request queue is full") + _SERVER_TASKS.append(task) + + task["done"].wait(5.0) + if not _event_is_set(task["done"]): + with _SERVER_TASKS_LOCK: + if task["started"]: + started = True + else: + started = False + task["cancelled"] = True + if task in _SERVER_TASKS: + _SERVER_TASKS.remove(task) + if started: + task["done"].wait() + else: + raise BridgeError("place_block timed out waiting for server thread") + if "error" in task: + raise BridgeError(task["error"]) + return task["result"] + + +def UPDATE(): + with _SERVER_TASKS_LOCK: + tasks = list(_SERVER_TASKS) + del _SERVER_TASKS[:] + + for task in tasks: + with _SERVER_TASKS_LOCK: + if task["cancelled"]: + task["done"].set() + continue + task["started"] = True + try: + task["result"] = _place_block_on_server(task["request"]) + except BridgeError as err: + task["error"] = str(err) + except Exception as err: + traceback.print_exc() + task["error"] = str(err) + finally: + task["done"].set() + + +def _cancel_server_tasks(): + with _SERVER_TASKS_LOCK: + tasks = list(_SERVER_TASKS) + del _SERVER_TASKS[:] + for task in tasks: + task["cancelled"] = True + task["error"] = "server stopped before executing request" + task["done"].set() + + +class MCPBridge(object): + def __init__(self, state_path): + self.state_path = state_path + self.sock = None + self.lock = threading.Lock() + self.running = False + self.connections = set() + self.accept_thread = None + self.client_threads = set() + self.port = None + self.tmp_path = None + + def start(self): + with self.lock: + if self.running: + return + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(("127.0.0.1", 0)) + sock.listen(4) + sock.settimeout(0.2) + self.sock = sock + self.running = True + port = sock.getsockname()[1] + self.port = port + + self._write_state(port) + print("[MCPBridge] Listening on 127.0.0.1:%d" % port) + thread = _create_daemon_thread(self._accept_loop) + with self.lock: + self.accept_thread = thread + thread.start() + + def close(self): + with self.lock: + sock = self.sock + self.sock = None + self.running = False + connections = list(self.connections) + self.connections.clear() + accept_thread = self.accept_thread + self.accept_thread = None + client_threads = list(self.client_threads) + self.client_threads.clear() + if sock: + try: + sock.shutdown(socket.SHUT_RDWR) + except Exception: + pass + try: + sock.close() + except Exception: + pass + for conn in connections: + try: + conn.shutdown(socket.SHUT_RDWR) + except Exception: + pass + try: + conn.close() + except Exception: + pass + if accept_thread and accept_thread.is_alive(): + accept_thread.join(1.0) + for thread in client_threads: + if thread.is_alive(): + thread.join(1.0) + self._delete_state() + + def _write_state(self, port): + state_dir = os.path.dirname(self.state_path) + if state_dir and not os.path.isdir(state_dir): + os.makedirs(state_dir) + tmp_path = "%s.%d.tmp" % (self.state_path, port) + self.tmp_path = tmp_path + data = json.dumps( + {"version": 1, "host": "127.0.0.1", "port": port}, + separators=(",", ":"), + ) + with open(tmp_path, "w") as fp: + fp.write(data) + os.rename(tmp_path, self.state_path) + self.tmp_path = None + + def _delete_state(self): + try: + if self.tmp_path and os.path.exists(self.tmp_path): + os.remove(self.tmp_path) + self.tmp_path = None + if not os.path.exists(self.state_path) or self.port is None: + return + with open(self.state_path, "r") as fp: + state = json.load(fp) + if state.get("host") == "127.0.0.1" and state.get("port") == self.port: + os.remove(self.state_path) + except Exception: + traceback.print_exc() + finally: + self.port = None + + def _accept_loop(self): + while True: + with self.lock: + sock = self.sock + running = self.running + if not running or sock is None: + return + try: + conn, _addr = sock.accept() + except socket.timeout: + continue + except socket.error: + with self.lock: + if not self.running: + return + traceback.print_exc() + return + except Exception: + with self.lock: + if not self.running: + return + traceback.print_exc() + return + with self.lock: + if not self.running: + conn.close() + return + self.connections.add(conn) + thread = _create_daemon_thread(self._handle_client, (conn,)) + self.client_threads.add(thread) + conn.settimeout(0.2) + thread.start() + + def _handle_client(self, conn): + try: + pending = b"" + while True: + with self.lock: + if not self.running: + return + try: + chunk = conn.recv(4096) + except socket.timeout: + continue + if not chunk: + return + pending += chunk + if len(pending) > MAX_REQUEST_BYTES: + raise BridgeError("request line exceeds maximum size") + while b"\n" in pending: + raw_line, pending = pending.split(b"\n", 1) + line = _to_text(raw_line).strip() + if line: + self._handle_line(conn, line) + except Exception: + with self.lock: + if self.running: + traceback.print_exc() + finally: + with self.lock: + self.connections.discard(conn) + self.client_threads.discard(threading.currentThread()) + try: + conn.close() + except Exception: + pass + + def _handle_line(self, conn, line): + request_id = None + try: + request = json.loads(line) + if not isinstance(request, dict): + raise BridgeError("request must be an object") + request_id = request.get("id") + method = request.get("method") + if method != "place_block": + raise BridgeError("unknown MCP bridge method: %s" % method) + result = _place_block(request.get("params", {})) + _send_json_line(conn, {"id": request_id, "ok": True, "result": result}) + except BridgeError as err: + _send_json_line(conn, {"id": request_id, "ok": False, "error": str(err)}) + except Exception as err: + traceback.print_exc() + _send_json_line(conn, {"id": request_id, "ok": False, "error": str(err)}) + + +def ON_SERVER_INIT(): + global _BRIDGE, _SERVER_ACTIVE + state_path = GET_MCP_BRIDGE_STATE_PATH() + if not state_path: + return + if _BRIDGE: + return + bridge = MCPBridge(state_path) + with _SERVER_TASKS_LOCK: + _SERVER_ACTIVE = True + try: + bridge.start() + except Exception: + with _SERVER_TASKS_LOCK: + _SERVER_ACTIVE = False + bridge.close() + raise + _BRIDGE = bridge + + +def ON_SERVER_EXIT(): + global _BRIDGE, _SERVER_ACTIVE + bridge = _BRIDGE + _BRIDGE = None + with _SERVER_TASKS_LOCK: + _SERVER_ACTIVE = False + _cancel_server_tasks() + if bridge: + bridge.close() diff --git a/examples/default/.gitignore b/examples/default/.gitignore index a89292c..0ee2472 100644 --- a/examples/default/.gitignore +++ b/examples/default/.gitignore @@ -1,4 +1,5 @@ .idea/ studio.json .mcs/editorSave.json -.mcs/images/ \ No newline at end of file +.mcs/images/ +.emod-cli/ \ No newline at end of file diff --git a/src/commands/mcp.rs b/src/commands/mcp.rs new file mode 100644 index 0000000..195cda6 --- /dev/null +++ b/src/commands/mcp.rs @@ -0,0 +1,15 @@ +use std::path::PathBuf; + +use crate::commands::McpArgs; + +pub fn execute(args: &McpArgs) { + let project_dir = args + .path + .as_deref() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + + if let Err(err) = crate::mcp::run_blocking(project_dir) { + eprintln!("Error: {}", err); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 69672d8..052b706 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -6,6 +6,7 @@ pub mod create; pub mod debug; pub mod fork_boss_engine; pub mod init; +pub mod mcp; pub mod release; #[derive(Parser)] @@ -34,6 +35,8 @@ pub enum Commands { Components(ComponentsArgs), /// Launch NetEase Minecraft with debug MOD, IPC logging, and hot reload Debug(DebugArgs), + /// Run stdio MCP server for the current debug session + Mcp(McpArgs), /// Convert Blockbench .bbmodel to NetEase block geometry Bbmodel(BbmodelArgs), /// Fork AbyssBossEngine into a new boss mod project @@ -122,6 +125,13 @@ pub struct DebugArgs { pub new_world: bool, } +#[derive(Args)] +pub struct McpArgs { + /// The path of the project (default: current directory) + #[arg(short, long)] + pub path: Option, +} + #[derive(Args)] pub struct ForkBossEngineArgs { /// Path to the AbyssBossEngine source project. diff --git a/src/debug/mcp.rs b/src/debug/mcp.rs new file mode 100644 index 0000000..298c303 --- /dev/null +++ b/src/debug/mcp.rs @@ -0,0 +1,56 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use crate::error::{CliError, Result}; + +pub const MCP_STATE_DIR: &str = ".emod-cli"; +pub const MCP_STATE_FILE: &str = "debug-mcp.json"; + +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +pub struct BridgeState { + pub version: u8, + pub host: String, + pub port: u16, +} + +pub fn state_file(project_dir: &Path) -> PathBuf { + project_dir.join(MCP_STATE_DIR).join(MCP_STATE_FILE) +} + +pub fn prepare_state_file(project_dir: &Path) -> Result { + let state_dir = project_dir.join(MCP_STATE_DIR); + fs::create_dir_all(&state_dir)?; + + let path = state_dir.join(MCP_STATE_FILE); + match fs::remove_file(&path) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(CliError::Io(err)), + } + Ok(path) +} + +pub fn read_state(project_dir: &Path) -> Result { + let path = state_file(project_dir); + let data = fs::read_to_string(path)?; + let state: BridgeState = serde_json::from_str(&data)?; + if state.version != 1 { + return Err(CliError::InvalidData(format!( + "unsupported debug MCP bridge state version {}", + state.version + ))); + } + if state.host.trim().is_empty() { + return Err(CliError::InvalidData( + "debug MCP bridge state host is empty".to_string(), + )); + } + if state.port == 0 { + return Err(CliError::InvalidData( + "debug MCP bridge state port is zero".to_string(), + )); + } + Ok(state) +} diff --git a/src/debug/mod.rs b/src/debug/mod.rs index 81b8a94..cba0347 100644 --- a/src/debug/mod.rs +++ b/src/debug/mod.rs @@ -5,6 +5,7 @@ pub mod hotreload; pub mod ipc; pub mod level; pub mod log; +pub mod mcp; pub mod nbt; pub mod process; pub mod win; @@ -24,6 +25,11 @@ pub fn run(project_dir: &Path, new_world: bool) -> Result<()> { config::ensure_game_executable(project_dir, &mut config)?; let mod_dirs = config.included_mod_dirs(project_dir)?; + let mcp_state_path = if config.include_debug_mod && !config::env_is_subprocess_mode() { + Some(mcp::prepare_state_file(project_dir)?) + } else { + None + }; let mut linked_packs = Vec::new(); if !config::env_is_subprocess_mode() { @@ -45,5 +51,10 @@ pub fn run(project_dir: &Path, new_world: bool) -> Result<()> { None }; - process::launch_game(&config, config_arg.as_deref(), &mod_dirs) + process::launch_game( + &config, + config_arg.as_deref(), + &mod_dirs, + mcp_state_path.as_deref(), + ) } diff --git a/src/debug/process.rs b/src/debug/process.rs index 0d596e7..f64a423 100644 --- a/src/debug/process.rs +++ b/src/debug/process.rs @@ -40,6 +40,7 @@ pub fn launch_game( config: &DebugConfig, config_arg: Option<&Path>, mod_dirs: &[ResolvedModDir], + mcp_state_path: Option<&Path>, ) -> Result<()> { let enable_ipc = config.auto_hot_reload_mods; let ipc = if enable_ipc { @@ -52,9 +53,10 @@ pub fn launch_game( let command = build_command(config, config_arg); let mut command_w = wide_null(OsStr::new(&command)); - let env_block = if ipc.is_some() || config.auto_hot_reload_ui { + let env_block = if ipc.is_some() || mcp_state_path.is_some() || config.auto_hot_reload_ui { Some(build_environment_block( ipc.as_ref().map(|server| server.port()), + mcp_state_path, )) } else { None @@ -154,6 +156,7 @@ pub fn launch_game( _config: &DebugConfig, _config_arg: Option<&Path>, _mod_dirs: &[ResolvedModDir], + _mcp_state_path: Option<&Path>, ) -> Result<()> { Err(CliError::InvalidInput( "debug launch is only supported on Windows".to_string(), @@ -230,11 +233,12 @@ where } #[cfg(windows)] -fn build_environment_block(ipc_port: Option) -> Vec { +fn build_environment_block(ipc_port: Option, mcp_state_path: Option<&Path>) -> Vec { let mut pairs: Vec<(Vec, Vec)> = std::env::vars_os() .filter(|(key, _)| { let key = key.to_string_lossy(); !key.eq_ignore_ascii_case("MCDEV_DEBUG_IPC_PORT") + && !key.eq_ignore_ascii_case("MCDEV_MCP_BRIDGE_STATE") }) .map(|(key, value)| (key.encode_wide().collect(), value.encode_wide().collect())) .collect(); @@ -245,6 +249,13 @@ fn build_environment_block(ipc_port: Option) -> Vec { OsStr::new(&port.to_string()).encode_wide().collect(), )); } + if let Some(path) = mcp_state_path { + let state_path = path.to_string_lossy().replace('\\', "/"); + pairs.push(( + OsStr::new("MCDEV_MCP_BRIDGE_STATE").encode_wide().collect(), + OsStr::new(&state_path).encode_wide().collect(), + )); + } pairs.sort_by(|a, b| a.0.cmp(&b.0)); let mut block = Vec::new(); diff --git a/src/main.rs b/src/main.rs index 8792e14..d3d1e34 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod commands; mod debug; mod entity; mod error; +mod mcp; mod template; mod utils; @@ -17,6 +18,7 @@ fn main() { Commands::Init(args) => commands::init::execute(args), Commands::Components(args) => commands::components::execute(args), Commands::Debug(args) => commands::debug::execute(args), + Commands::Mcp(args) => commands::mcp::execute(args), Commands::Bbmodel(args) => commands::bbmodel::execute(args), Commands::ForkBossEngine(args) => commands::fork_boss_engine::execute(args), } diff --git a/src/mcp.rs b/src/mcp.rs new file mode 100644 index 0000000..4d0cd05 --- /dev/null +++ b/src/mcp.rs @@ -0,0 +1,269 @@ +use std::{ + io::{BufRead, BufReader, ErrorKind, Write}, + net::{TcpStream, ToSocketAddrs}, + path::{Path, PathBuf}, + time::Duration, +}; + +use rmcp::{ + ErrorData as McpError, ServerHandler, ServiceExt, + handler::server::{tool::ToolRouter, wrapper::Parameters}, + model::{CallToolResult, Content, ServerCapabilities, ServerInfo}, + schemars::{self, JsonSchema}, + tool, tool_handler, tool_router, + transport::stdio, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +use crate::{debug::mcp as debug_mcp, error::CliError}; + +const BRIDGE_NOT_READY: &str = "debug MCP bridge is not ready; start emod-cli debug --path and wait for [MCPBridge] Listening"; +const CONNECT_TIMEOUT: Duration = Duration::from_secs(1); +const IO_TIMEOUT: Duration = Duration::from_secs(6); + +#[derive(Clone)] +pub struct DebugMcpServer { + project_dir: PathBuf, + tool_router: ToolRouter, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct PlaceBlockParams { + pub x: i32, + pub y: i32, + pub z: i32, + pub name: String, + #[serde(default)] + pub dimension: Option, + #[serde(default)] + pub aux: Option, + #[serde(default)] + pub states: Option>, + #[serde(default)] + pub old_block_handling: Option, + #[serde(default)] + pub is_legacy: Option, + #[serde(default)] + pub update_neighbors: Option, +} + +#[derive(Debug)] +enum BridgeCallError { + NotReady, + Message(String), +} + +#[derive(Serialize)] +struct BridgeRequest { + id: u64, + method: &'static str, + params: Value, +} + +#[derive(Deserialize)] +struct BridgeResponse { + ok: bool, + result: Option, + error: Option, +} + +#[tool_router] +impl DebugMcpServer { + fn new(project_dir: PathBuf) -> Self { + Self { + project_dir, + tool_router: Self::tool_router(), + } + } + + #[tool( + description = "Place a block in the currently running emod-cli debug world through the injected debug MCP bridge" + )] + async fn place_block( + &self, + Parameters(params): Parameters, + ) -> Result { + validate_place_block_params(¶ms)?; + + match call_bridge(&self.project_dir, ¶ms) { + Ok(result) => { + let text = serde_json::to_string_pretty(&result).map_err(|err| { + McpError::internal_error(format!("failed to encode bridge result: {err}"), None) + })?; + Ok(CallToolResult::success(vec![Content::text(text)])) + } + Err(BridgeCallError::NotReady) => Ok(tool_error(BRIDGE_NOT_READY)), + Err(BridgeCallError::Message(message)) => Ok(tool_error(message)), + } + } +} + +#[tool_handler] +impl ServerHandler for DebugMcpServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + instructions: Some( + "Place blocks in a running emod-cli debug world through the injected debug MCP bridge." + .into(), + ), + capabilities: ServerCapabilities::builder().enable_tools().build(), + ..Default::default() + } + } +} + +pub fn run_blocking(project_dir: PathBuf) -> std::result::Result<(), Box> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build()?; + + runtime.block_on(async move { + let service = DebugMcpServer::new(project_dir).serve(stdio()).await?; + service.waiting().await?; + Ok(()) + }) +} + +fn validate_place_block_params(params: &PlaceBlockParams) -> Result<(), McpError> { + if params.name.trim().is_empty() || !params.name.contains(':') { + return Err(McpError::invalid_params( + "place_block.name must be a non-empty namespaced block id", + None, + )); + } + if params.aux.is_some() && params.states.is_some() { + return Err(McpError::invalid_params( + "place_block.aux and place_block.states cannot both be provided", + None, + )); + } + if let Some(old_block_handling) = params.old_block_handling { + if !(0..=2).contains(&old_block_handling) { + return Err(McpError::invalid_params( + "place_block.old_block_handling must be 0, 1, or 2", + None, + )); + } + } + Ok(()) +} + +fn tool_error(message: impl Into) -> CallToolResult { + CallToolResult::error(vec![Content::text(message.into())]) +} + +fn call_bridge(project_dir: &Path, params: &PlaceBlockParams) -> Result { + let state = read_bridge_state(project_dir)?; + if state.host != "127.0.0.1" && state.host != "localhost" { + return Err(BridgeCallError::Message( + CliError::InvalidData(format!( + "debug MCP bridge host is not local: {}", + state.host + )) + .to_string(), + )); + } + + let mut addrs = (state.host.as_str(), state.port) + .to_socket_addrs() + .map_err(|err| { + BridgeCallError::Message(format!("failed to resolve debug MCP bridge: {err}")) + })?; + let addr = addrs.next().ok_or_else(|| { + BridgeCallError::Message("debug MCP bridge address did not resolve".to_string()) + })?; + + let mut stream = TcpStream::connect_timeout(&addr, CONNECT_TIMEOUT).map_err(|err| { + BridgeCallError::Message(format!("failed to connect debug MCP bridge: {err}")) + })?; + stream.set_read_timeout(Some(IO_TIMEOUT)).map_err(|err| { + BridgeCallError::Message(format!("failed to set bridge read timeout: {err}")) + })?; + stream.set_write_timeout(Some(IO_TIMEOUT)).map_err(|err| { + BridgeCallError::Message(format!("failed to set bridge write timeout: {err}")) + })?; + + let request = BridgeRequest { + id: 1, + method: "place_block", + params: bridge_params(params), + }; + serde_json::to_writer(&mut stream, &request).map_err(|err| { + BridgeCallError::Message(format!("failed to encode bridge request: {err}")) + })?; + stream.write_all(b"\n").map_err(|err| { + BridgeCallError::Message(format!("failed to write bridge request: {err}")) + })?; + stream.flush().map_err(|err| { + BridgeCallError::Message(format!("failed to flush bridge request: {err}")) + })?; + + let mut reader = BufReader::new(stream); + let mut line = String::new(); + let bytes = reader.read_line(&mut line).map_err(|err| { + BridgeCallError::Message(format!("failed to read bridge response: {err}")) + })?; + if bytes == 0 { + return Err(BridgeCallError::Message( + "debug MCP bridge closed connection without response".to_string(), + )); + } + + let response: BridgeResponse = serde_json::from_str(line.trim()).map_err(|err| { + BridgeCallError::Message(format!("failed to decode bridge response: {err}")) + })?; + if !response.ok { + return Err(BridgeCallError::Message(response.error.unwrap_or_else( + || "debug MCP bridge returned an error".to_string(), + ))); + } + response.result.ok_or_else(|| { + BridgeCallError::Message("debug MCP bridge response is missing result".to_string()) + }) +} + +fn read_bridge_state(project_dir: &Path) -> Result { + match debug_mcp::read_state(project_dir) { + Ok(state) => Ok(state), + Err(CliError::Io(err)) if err.kind() == ErrorKind::NotFound => { + Err(BridgeCallError::NotReady) + } + Err(err) => Err(BridgeCallError::Message(err.to_string())), + } +} + +fn bridge_params(params: &PlaceBlockParams) -> Value { + let mut map = Map::new(); + map.insert("x".to_string(), json!(params.x)); + map.insert("y".to_string(), json!(params.y)); + map.insert("z".to_string(), json!(params.z)); + map.insert( + "dimension".to_string(), + json!(params.dimension.unwrap_or(0)), + ); + map.insert("name".to_string(), json!(params.name)); + match ¶ms.states { + Some(states) => { + map.insert("aux".to_string(), Value::Null); + map.insert("states".to_string(), Value::Object(states.clone())); + } + None => { + map.insert("aux".to_string(), json!(params.aux.unwrap_or(0))); + map.insert("states".to_string(), Value::Null); + } + } + map.insert( + "old_block_handling".to_string(), + json!(params.old_block_handling.unwrap_or(0)), + ); + map.insert( + "is_legacy".to_string(), + json!(params.is_legacy.unwrap_or(true)), + ); + map.insert( + "update_neighbors".to_string(), + json!(params.update_neighbors.unwrap_or(true)), + ); + Value::Object(map) +}