diff --git a/Cargo.lock b/Cargo.lock index 70336fb..1430529 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "addr2line" -version = "0.24.2" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" dependencies = [ "gimli", ] @@ -26,15 +26,6 @@ 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 = "anyhow" version = "1.0.100" @@ -42,16 +33,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] -name = "autocfg" -version = "1.5.0" +name = "async-trait" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "backtrace" -version = "0.3.75" +version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ "addr2line", "cfg-if", @@ -59,7 +55,7 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-targets", + "windows-link", ] [[package]] @@ -86,42 +82,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" -[[package]] -name = "cc" -version = "1.2.38" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80f41ae168f955c12fb8960b057d70d0ca153fb83182b57d86380443527be7e9" -dependencies = [ - "find-msvc-tools", - "shlex", -] - [[package]] name = "cfg-if" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" -[[package]] -name = "chrono" -version = "0.4.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -137,41 +103,6 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "darling" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" -dependencies = [ - "darling_core", - "quote", - "syn", -] - [[package]] name = "deranged" version = "0.5.4" @@ -199,26 +130,9 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - [[package]] name = "expand-tilde" version = "0.6.1" @@ -226,28 +140,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af42bffb99c712d4a07c17215b28e22f448b02e9d1481a8a5e05ebd837737f0c" dependencies = [ "home", - "thiserror 2.0.16", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959" - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", + "thiserror 2.0.17", ] [[package]] @@ -347,159 +240,34 @@ checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", ] [[package]] -name = "gimli" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" - -[[package]] -name = "home" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" -dependencies = [ - "windows-sys 0.59.0", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" - -[[package]] -name = "icu_properties" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "potential_utf", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" - -[[package]] -name = "icu_provider" -version = "2.0.0" +name = "getrandom" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ - "displaydoc", - "icu_locale_core", - "stable_deref_trait", - "tinystr", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.7+wasi-0.2.4", ] [[package]] -name = "ident_case" -version = "1.0.1" +name = "gimli" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" +name = "home" +version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" dependencies = [ - "icu_normalizer", - "icu_properties", + "windows-sys 0.59.0", ] [[package]] @@ -537,9 +305,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.176" +version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" [[package]] name = "libredox" @@ -553,10 +321,13 @@ dependencies = [ ] [[package]] -name = "litemap" -version = "0.8.0" +name = "lock_api" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] [[package]] name = "log" @@ -578,18 +349,16 @@ name = "mcp_linux_ssh" version = "0.1.1" dependencies = [ "anyhow", + "async-trait", "directories", "expand-tilde", - "percent-encoding", - "rmcp", - "schemars", + "rust-mcp-sdk", "serde", "serde_json", "tokio", "tracing", "tracing-appender", "tracing-subscriber", - "url", "whoami", ] @@ -615,7 +384,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.59.0", ] @@ -634,20 +403,11 @@ 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 = "object" -version = "0.36.7" +version = "0.37.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" dependencies = [ "memchr", ] @@ -665,16 +425,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] -name = "paste" -version = "1.0.15" +name = "parking_lot" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] [[package]] -name = "percent-encoding" -version = "2.3.2" +name = "parking_lot_core" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] [[package]] name = "pin-project-lite" @@ -688,15 +459,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" -[[package]] -name = "potential_utf" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" -dependencies = [ - "zerovec", -] - [[package]] name = "powerfmt" version = "0.2.0" @@ -714,18 +476,24 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.40" +version = "1.0.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "redox_syscall" -version = "0.5.17" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ "bitflags", ] @@ -736,80 +504,87 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom", + "getrandom 0.2.16", "libredox", - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] -name = "ref-cast" -version = "1.0.24" +name = "regex-automata" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf" +checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" dependencies = [ - "ref-cast-impl", + "aho-corasick", + "memchr", + "regex-syntax", ] [[package]] -name = "ref-cast-impl" -version = "1.0.24" +name = "regex-syntax" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" + +[[package]] +name = "rust-mcp-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b647a85c9da2eaf14e67d39cb067a8157a66bd2c0dc53ef1051a84f45edfae24" dependencies = [ "proc-macro2", "quote", + "serde", + "serde_json", "syn", ] [[package]] -name = "regex-automata" -version = "0.4.11" +name = "rust-mcp-schema" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" +checksum = "ba217e6fcb043bba9e194209bff92c35294093187504d1443832ca2051816753" dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", + "serde", + "serde_json", ] [[package]] -name = "regex-syntax" -version = "0.8.6" +name = "rust-mcp-sdk" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" - -[[package]] -name = "rmcp" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41ab0892f4938752b34ae47cb53910b1b0921e55e77ddb6e44df666cab17939f" +checksum = "961ec01d0bedecf488388e6b1cf04170f9badab4927061c6592ffa385c02c6c9" dependencies = [ + "async-trait", "base64", - "chrono", "futures", - "paste", - "pin-project-lite", - "rmcp-macros", - "schemars", + "rust-mcp-macros", + "rust-mcp-schema", + "rust-mcp-transport", "serde", "serde_json", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", - "tokio-util", "tracing", + "uuid", ] [[package]] -name = "rmcp-macros" -version = "0.6.4" +name = "rust-mcp-transport" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1827cd98dab34cade0513243c6fe0351f0f0b2c9d6825460bcf45b42804bdda0" +checksum = "35feabc5e4667019dc262178724c94cbced6f43959af15e214b52f79243f55ed" dependencies = [ - "darling", - "proc-macro2", - "quote", + "async-trait", + "bytes", + "futures", + "rust-mcp-schema", + "serde", "serde_json", - "syn", + "thiserror 2.0.17", + "tokio", + "tokio-stream", + "tracing", ] [[package]] @@ -831,36 +606,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] -name = "schemars" -version = "1.0.4" +name = "scopeguard" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" -dependencies = [ - "chrono", - "dyn-clone", - "ref-cast", - "schemars_derive", - "serde", - "serde_json", -] - -[[package]] -name = "schemars_derive" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33d020396d1d138dc19f1165df7545479dcd58d93810dc5d646a16e55abefa80" -dependencies = [ - "proc-macro2", - "quote", - "serde_derive_internals", - "syn", -] +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", @@ -868,29 +623,18 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_derive_internals" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -919,12 +663,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - [[package]] name = "signal-hook-registry" version = "1.4.6" @@ -947,16 +685,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] -name = "stable_deref_trait" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" - -[[package]] -name = "strsim" -version = "0.11.1" +name = "socket2" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] [[package]] name = "syn" @@ -969,17 +705,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -991,11 +716,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.16" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl 2.0.16", + "thiserror-impl 2.0.17", ] [[package]] @@ -1011,9 +736,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.16" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", @@ -1060,16 +785,6 @@ dependencies = [ "time-core", ] -[[package]] -name = "tinystr" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" -dependencies = [ - "displaydoc", - "zerovec", -] - [[package]] name = "tokio" version = "1.47.1" @@ -1081,9 +796,11 @@ dependencies = [ "io-uring", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "slab", + "socket2", "tokio-macros", "windows-sys 0.59.0", ] @@ -1100,14 +817,12 @@ dependencies = [ ] [[package]] -name = "tokio-util" -version = "0.7.16" +name = "tokio-stream" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" dependencies = [ - "bytes", "futures-core", - "futures-sink", "pin-project-lite", "tokio", ] @@ -1205,23 +920,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" [[package]] -name = "url" -version = "2.5.7" +name = "uuid" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", + "getrandom 0.3.3", + "js-sys", + "wasm-bindgen", ] -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - [[package]] name = "valuable" version = "0.1.1" @@ -1234,6 +942,24 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasite" version = "0.1.0" @@ -1320,64 +1046,11 @@ dependencies = [ "web-sys", ] -[[package]] -name = "windows-core" -version = "0.62.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "windows-link" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" - -[[package]] -name = "windows-result" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" -dependencies = [ - "windows-link", -] +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-sys" @@ -1397,6 +1070,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -1462,85 +1144,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "writeable" -version = "0.6.1" +name = "wit-bindgen" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" - -[[package]] -name = "yoke" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" -dependencies = [ - "serde", - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerotrie" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" diff --git a/Cargo.toml b/Cargo.toml index 205a639..943d3e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,9 +11,6 @@ license = "MIT" [dependencies] anyhow = "1.0.99" expand-tilde = "0.6.1" -percent-encoding = "2.3.2" -rmcp = { version = "0.6.4", features = ["transport-io"] } -schemars = "1.0.4" serde = "1.0.219" serde_json = "1.0.143" tokio = { version = "1.47.1", features = [ @@ -29,6 +26,12 @@ tracing-subscriber = { version = "0.3.20", features = [ "env-filter", "json", ] } -url = "2.5.7" whoami = "1.5.2" directories = "6.0.0" +rust-mcp-sdk = { version = "0.7.0", default-features = false, features = [ + "server", + "macros", + "stdio", + "2025_06_18", +] } +async-trait = "0.1.89" diff --git a/src/handler.rs b/src/handler.rs new file mode 100644 index 0000000..3fa3747 --- /dev/null +++ b/src/handler.rs @@ -0,0 +1,44 @@ +use async_trait::async_trait; +use rust_mcp_sdk::schema::schema_utils::CallToolError; +use rust_mcp_sdk::schema::{ + CallToolRequest, CallToolResult, ListToolsRequest, ListToolsResult, RpcError, +}; +use rust_mcp_sdk::{McpServer, mcp_server::ServerHandler}; +use std::sync::Arc; + +use crate::tools::POSIXSSHTools; + +pub struct POSIXSSHHandler; + +#[async_trait] +impl ServerHandler for POSIXSSHHandler { + /// Handle list tool requests + async fn handle_list_tools_request( + &self, + _: ListToolsRequest, + _: Arc, + ) -> std::result::Result { + Ok(ListToolsResult { + meta: None, + next_cursor: None, + tools: POSIXSSHTools::tools(), + }) + } + + /// Handle tool call requests + async fn handle_call_tool_request( + &self, + request: CallToolRequest, + _: Arc, + ) -> std::result::Result { + let params = POSIXSSHTools::try_from(request.params).map_err(CallToolError::new)?; + + match params { + POSIXSSHTools::RunLocalCommand(tool) => tool.call_tool().await, + POSIXSSHTools::RunSSHCommand(tool) => tool.call_tool().await, + POSIXSSHTools::RunSSHSudoCommand(tool) => tool.call_tool().await, + POSIXSSHTools::CopyFile(tool) => tool.call_tool().await, + POSIXSSHTools::PatchFile(tool) => tool.call_tool().await, + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 0e37bd3..cee96e4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,732 +1,2 @@ -use anyhow::Result; -use expand_tilde::expand_tilde; -use rmcp::{ - ErrorData, RoleServer, - handler::server::{ - ServerHandler, router::prompt::PromptRouter, tool::ToolRouter, wrapper::Parameters, - }, - model::{ - AnnotateAble, CallToolResult, GetPromptRequestParam, GetPromptResult, Implementation, - ListPromptsResult, ListResourceTemplatesResult, ListResourcesResult, PaginatedRequestParam, - RawResource, ReadResourceRequestParam, ReadResourceResult, ResourceContents, - ServerCapabilities, ServerInfo, - }, - prompt_handler, prompt_router, - service::RequestContext, - tool, tool_handler, tool_router, -}; -use schemars::JsonSchema; -use serde::Deserialize; -use serde_json::json; -use std::{fs, ops::Deref}; -use tokio::io::AsyncWriteExt; -use tokio::process::Command; -use tokio::time::{Duration, timeout}; -use url::Url; - -/// Handler for the MCP server. -#[derive(Clone, Debug, Default)] -pub struct Handler { - tool_router: ToolRouter, - prompt_router: PromptRouter, -} - -/// Common SSH connection parameters shared across multiple tools. -#[derive(Clone, Debug, Deserialize, JsonSchema)] -pub struct SshConnectionParams { - /// The user to run the command as. Defaults to the current username. - pub remote_user: Option, - /// The host to run the command on. - pub remote_host: String, - /// Path to the private key to use for authentication. Defaults to - /// ~/.ssh/id_ed25519. - pub private_key: Option, - /// Timeout in seconds for the command execution. Defaults to 30 seconds. - /// Set to 0 to disable timeout. - pub timeout_seconds: Option, - /// Additional options to pass to the ssh command. Each option should be a - /// key-value pair separated by an equal sign (=). The options are passed - /// to the ssh command using the -o flag. - pub options: Option>, -} - -#[derive(Clone, Debug, Deserialize, JsonSchema)] -pub struct RunCommandSshParams { - /// The command to run. This must be a single command. Arguments must be - /// passed in the args parameter. - pub command: String, - /// The arguments to pass to the command. - pub args: Vec, - #[serde(flatten)] - #[schemars(flatten)] - pub ssh: SshConnectionParams, -} - -#[derive(Clone, Debug, Deserialize, JsonSchema)] -pub struct RunCommandLocalParams { - /// The command to run. This must be a single command. Arguments must be - /// passed in the args parameter. - pub command: String, - /// The arguments to pass to the command. - pub args: Vec, - /// Timeout in seconds for the command execution. Defaults to 30 seconds. - /// Set to 0 to disable timeout. - pub timeout_seconds: Option, -} - -#[derive(Clone, Debug, Deserialize, JsonSchema)] -pub struct CopyFileParams { - /// The source file path on the local machine. - pub source: String, - /// The destination file path on the remote machine. - pub destination: String, - #[serde(flatten)] - #[schemars(flatten)] - pub ssh: SshConnectionParams, -} - -#[derive(Clone, Debug, Deserialize, JsonSchema)] -pub struct PatchFileParams { - /// The patch/diff content to apply. - pub patch: String, - /// The path to the file on the remote machine to patch. - pub remote_file: String, - #[serde(flatten)] - #[schemars(flatten)] - pub ssh: SshConnectionParams, -} - -#[tool_router] -#[prompt_router] -impl Handler { - pub fn new() -> Self { - Self { - tool_router: Self::tool_router(), - prompt_router: Self::prompt_router(), - } - } - - #[tool( - name = "Run", - description = "Run a command on the local system and return the output. \ - Use this sparingly; only when needed to troubleshoot why connecting to the \ - remote system is failing." - )] - #[tracing::instrument(skip(self))] - pub async fn run_command_local( - &self, - params: Parameters, - ) -> Result { - let _span = tracing::span!(tracing::Level::DEBUG, "run_command_local", params = ?params); - let _enter = _span.enter(); - - let command = params.0.command; - let args = params.0.args; - let timeout_seconds = params.0.timeout_seconds.unwrap_or(30); - - let command_future = Command::new(&command).args(&args).output(); - - let result = if timeout_seconds == 0 { - // No timeout - run indefinitely - command_future.await - } else { - // Apply timeout - let timeout_duration = Duration::from_secs(timeout_seconds); - match timeout(timeout_duration, command_future).await { - Ok(result) => result, - Err(_) => { - return Err(ErrorData::internal_error( - format!("Local command timed out after {} seconds", timeout_seconds), - None, - )); - } - } - }; - - match result { - Ok(output) => { - // The command executed successfully. This doesn't mean it - // succeeded, so output is returned as a successful tool call. - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let status_code = output.status.code(); - - Ok(CallToolResult::structured(json!({ - "status_code": status_code, - "stdout": stdout.trim().to_string(), - "stderr": stderr.trim().to_string(), - }))) - } - Err(e) => Err(ErrorData::internal_error( - // The command failed to execute. Return the error to the caller. - format!("Failed to execute local command: {}", e), - None, - )), - } - } - - #[tool( - name = "SSH", - description = "Run a command on a remote POSIX compatible system (Linux, \ - BSD, macOS) system and return the output. This tool does not permit commands \ - to be run with sudo." - )] - #[tracing::instrument(skip(self))] - pub async fn run_command_ssh( - &self, - params: Parameters, - ) -> Result { - let _span = tracing::span!(tracing::Level::TRACE, "run_command_ssh", params = ?params); - let _enter = _span.enter(); - - let command = params.0.command; - let args = params.0.args; - let remote_user = params.0.ssh.remote_user.unwrap_or(whoami::username()); - let remote_host = params.0.ssh.remote_host; - let private_key = params - .0 - .ssh - .private_key - .unwrap_or("~/.ssh/id_ed25519".to_string()); - let timeout_seconds = params.0.ssh.timeout_seconds.unwrap_or(30); - let options_vec: Option> = params - .0 - .ssh - .options - .as_ref() - .map(|v| v.iter().map(String::as_str).collect()); - - if command.contains("sudo") || args.iter().any(|arg| arg.contains("sudo")) { - // sudo is not permitted for this tool. - return Err(ErrorData::invalid_request( - "You many not run commands with sudo using this tool".to_string(), - None, - )); - } - - match exec_ssh( - &remote_user, - &remote_host, - &private_key, - &command, - &args.iter().map(|arg| arg.as_str()).collect::>(), - timeout_seconds, - options_vec.as_deref(), - ) - .await - { - Ok(output) => { - // The command executed successfully. This doesn't mean it - // succeeded, so output is returned as a successful tool call. - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let status_code = output.status.code(); - - Ok(CallToolResult::structured(json!({ - "status_code": status_code, - "stdout": stdout.trim().to_string(), - "stderr": stderr.trim().to_string(), - }))) - } - Err(e) => Err(ErrorData::internal_error( - // The command failed to execute. Return the error to the caller. - format!("Failed to execute remote SSH command: {}", e), - None, - )), - } - } - - #[tool( - name = "SSH_Sudo", - description = "Run a command on a remote POSIX compatible system (Linux, \ - BSD, macOS) system and return the output. This tool explicitly runs \ - commands with sudo." - )] - #[tracing::instrument(skip(self))] - pub async fn run_command_ssh_sudo( - &self, - params: Parameters, - ) -> Result { - let _span = tracing::span!(tracing::Level::TRACE, "run_command_ssh_sudo", params = ?params); - let _enter = _span.enter(); - - let command = params.0.command; - let args = params.0.args; - let remote_user = params.0.ssh.remote_user.unwrap_or(whoami::username()); - let remote_host = params.0.ssh.remote_host; - let private_key = params - .0 - .ssh - .private_key - .unwrap_or("~/.ssh/id_ed25519".to_string()); - let timeout_seconds = params.0.ssh.timeout_seconds.unwrap_or(30); - let options_vec: Option> = params - .0 - .ssh - .options - .as_ref() - .map(|v| v.iter().map(String::as_str).collect()); - - match exec_ssh( - &remote_user, - &remote_host, - &private_key, - "sudo", - std::iter::once(command.as_str()) - .chain(args.iter().map(|arg| arg.as_str())) - .collect::>() - .as_slice(), - timeout_seconds, - options_vec.as_deref(), - ) - .await - { - Ok(output) => { - // The command executed successfully. This doesn't mean it - // succeeded, so output is returned as a successful tool call. - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let status_code = output.status.code(); - - Ok(CallToolResult::structured(json!({ - "status_code": status_code, - "stdout": stdout.trim().to_string(), - "stderr": stderr.trim().to_string(), - }))) - } - Err(e) => Err(ErrorData::internal_error( - // The command failed to execute. Return the error to the caller. - format!("Failed to execute remote SSH command with sudo: {}", e), - None, - )), - } - } - - #[tool( - name = "Copy_File", - description = "Copy a file from the local machine to the remote machine using rsync. \ - Preserves file attributes and creates a backup if the destination file already exists." - )] - #[tracing::instrument(skip(self))] - pub async fn copy_file( - &self, - params: Parameters, - ) -> Result { - let _span = tracing::span!(tracing::Level::TRACE, "copy_file", params = ?params); - let _enter = _span.enter(); - - let source = expand_tilde(¶ms.0.source).map_err(|e| { - ErrorData::internal_error(format!("Failed to expand source path: {}", e), None) - })?; - let destination = params.0.destination; - let remote_user = params.0.ssh.remote_user.unwrap_or(whoami::username()); - let remote_host = params.0.ssh.remote_host; - let private_key = params - .0 - .ssh - .private_key - .unwrap_or("~/.ssh/id_ed25519".to_string()); - let timeout_seconds = params.0.ssh.timeout_seconds.unwrap_or(30); - - // Expand the private key path - let expanded_key = expand_tilde(&private_key).map_err(|e| { - ErrorData::internal_error(format!("Failed to expand private key path: {}", e), None) - })?; - let private_key_path = expanded_key.deref().as_os_str().to_str().ok_or_else(|| { - ErrorData::internal_error( - format!("Failed to convert private key to string: {}", private_key), - None, - ) - })?; - - let ssh_command = format!("ssh -i {}", private_key_path); - let remote_target = format!("{}@{}:{}", remote_user, remote_host, destination); - - // Build the rsync command - // -a: archive mode (preserves permissions, timestamps, etc.) - // -v: verbose - // -b: create backups of existing files - // -e: specify ssh command with identity file - let command_future = Command::new("rsync") - .arg("-avb") - .arg("-e") - .arg(&ssh_command) - .arg(source.to_string_lossy().into_owned()) - .arg(&remote_target) - .output(); - - let result = if timeout_seconds == 0 { - // No timeout - run indefinitely - command_future.await - } else { - // Apply timeout - let timeout_duration = Duration::from_secs(timeout_seconds); - match timeout(timeout_duration, command_future).await { - Ok(result) => result, - Err(_) => { - return Err(ErrorData::internal_error( - format!("rsync command timed out after {} seconds", timeout_seconds), - None, - )); - } - } - }; - - match result { - Ok(output) => { - // The command executed successfully. This doesn't mean it - // succeeded, so output is returned as a successful tool call. - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let status_code = output.status.code(); - - Ok(CallToolResult::structured(json!({ - "status_code": status_code, - "stdout": stdout.trim().to_string(), - "stderr": stderr.trim().to_string(), - }))) - } - Err(e) => Err(ErrorData::internal_error( - // The command failed to execute. Return the error to the caller. - format!("Failed to execute rsync command: {}", e), - None, - )), - } - } - - #[tool( - name = "Patch_File", - description = "Apply a patch or diff to a file on the remote machine using the patch command. \ - The patch content is streamed via stdin over SSH. By default, patch will attempt to \ - automatically detect the correct strip level (-p). Use unified diff format for best results." - )] - #[tracing::instrument(skip(self))] - pub async fn patch_file( - &self, - params: Parameters, - ) -> Result { - let _span = tracing::span!(tracing::Level::TRACE, "patch_file", params = ?params); - let _enter = _span.enter(); - - let patch = params.0.patch; - let remote_file = params.0.remote_file; - let remote_user = params.0.ssh.remote_user.unwrap_or(whoami::username()); - let remote_host = params.0.ssh.remote_host; - let private_key = params - .0 - .ssh - .private_key - .unwrap_or("~/.ssh/id_ed25519".to_string()); - let timeout_seconds = params.0.ssh.timeout_seconds.unwrap_or(30); - let options_vec: Option> = params - .0 - .ssh - .options - .as_ref() - .map(|v| v.iter().map(String::as_str).collect()); - - // Expand the private key path - let expanded_key = expand_tilde(&private_key).map_err(|e| { - ErrorData::internal_error(format!("Failed to expand private key path: {}", e), None) - })?; - let private_key_path = expanded_key.deref().as_os_str().to_str().ok_or_else(|| { - ErrorData::internal_error( - format!("Failed to convert private key to string: {}", private_key), - None, - ) - })?; - - // Build SSH command that will run patch on the remote side - // The patch command reads from stdin and applies to the specified file - let mut cmd = Command::new("ssh"); - cmd.arg(&remote_host) - .args(["-l", &remote_user]) - .args(["-i", private_key_path]) - .args( - options_vec - .unwrap_or_default() - .iter() - .flat_map(|opt| ["-o", opt]), - ) - .arg("patch") - .arg(&remote_file) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()); - - let command_future = async { - let mut child = cmd.spawn().map_err(|e| { - ErrorData::internal_error(format!("Failed to spawn SSH command: {}", e), None) - })?; - - // Write the patch content to stdin - if let Some(mut stdin) = child.stdin.take() { - stdin.write_all(patch.as_bytes()).await.map_err(|e| { - ErrorData::internal_error( - format!("Failed to write patch to stdin: {}", e), - None, - ) - })?; - // Close stdin to signal EOF - drop(stdin); - } - - // Wait for the command to complete - child.wait_with_output().await.map_err(|e| { - ErrorData::internal_error(format!("Failed to wait for SSH command: {}", e), None) - }) - }; - - let result = if timeout_seconds == 0 { - // No timeout - run indefinitely - command_future.await - } else { - // Apply timeout - let timeout_duration = Duration::from_secs(timeout_seconds); - match timeout(timeout_duration, command_future).await { - Ok(result) => result, - Err(_) => { - return Err(ErrorData::internal_error( - format!("Patch command timed out after {} seconds", timeout_seconds), - None, - )); - } - } - }; - - match result { - Ok(output) => { - // The command executed successfully. This doesn't mean it - // succeeded, so output is returned as a successful tool call. - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let status_code = output.status.code(); - - Ok(CallToolResult::structured(json!({ - "status_code": status_code, - "stdout": stdout.trim().to_string(), - "stderr": stderr.trim().to_string(), - }))) - } - Err(e) => Err(e), - } - } -} - -#[tool_handler] -#[prompt_handler] -impl ServerHandler for Handler { - fn get_info(&self) -> ServerInfo { - ServerInfo { - server_info: Implementation { - name: String::from("Linux admin utilities"), - title: Some(String::from("POSIX administration using SSH")), - ..Default::default() - }, - instructions: Some(String::from( - "You are an expert POSIX compatible system (Linux, BSD, macOS) system \ - administrator. You run commands on a remote POSIX compatible system \ - (Linux, BSD, macOS) system to troubleshoot, fix issues and perform \ - general administration.", - )), - capabilities: ServerCapabilities::builder() - .enable_tools() - .enable_prompts() - .enable_resources() - .build(), - ..Default::default() - } - } - - async fn list_resources( - &self, - _request: Option, - _context: RequestContext, - ) -> Result { - Ok(ListResourcesResult { - resources: vec![ - RawResource { - uri: "file:///public_keys".to_string(), - name: "public_keys".to_string(), - title: Some("public_keys".to_string()), - description: Some("List public keys available on the local system".to_string()), - mime_type: Some("text/plain".to_string()), - size: None, - icons: None, - } - .no_annotation(), - ], - next_cursor: None, - }) - } - - async fn list_resource_templates( - &self, - _request: Option, - _context: RequestContext, - ) -> Result { - Ok(ListResourceTemplatesResult { - resource_templates: vec![], - next_cursor: None, - }) - } - - #[tracing::instrument(skip(self, _context))] - async fn read_resource( - &self, - request: ReadResourceRequestParam, - _context: RequestContext, - ) -> Result { - let _span = tracing::span!(tracing::Level::TRACE, "read_resource", uri = %request.uri); - let _enter = _span.enter(); - - // Parse the URI into a URL struct - let url = Url::parse(&request.uri) - .map_err(|e| ErrorData::invalid_request(format!("Invalid URI: {}", e), None))?; - - match url.scheme() { - "file" => { - let path = url.to_file_path().map_err(|_| { - ErrorData::invalid_request("Invalid file URI".to_string(), None) - })?; - let path_str = path.to_str().ok_or_else(|| { - ErrorData::invalid_request("Cannot convert path to string", None) - })?; - - match path_str { - "/public_keys" => { - // Find all public keys in ~/.ssh - let base_dirs = directories::BaseDirs::new().ok_or_else(|| { - ErrorData::internal_error("Cannot get home directory", None) - })?; - let home_dir = base_dirs.home_dir(); - let ssh_dir = home_dir.join(".ssh"); - let iter = fs::read_dir(&ssh_dir).map_err(|e| { - ErrorData::internal_error( - format!("Failed to read directory: {}", e), - None, - ) - })?; - - let mut public_keys = Vec::new(); - for entry in iter { - let entry = entry.map_err(|e| { - ErrorData::internal_error( - format!("Failed to read entry: {}", e), - None, - ) - })?; - let path = entry.path(); - if path.is_file() && path.extension() == Some("pub".as_ref()) { - let basename = path - .file_name() - .ok_or_else(|| { - ErrorData::internal_error( - format!( - "Cannot get base name for file {}", - path.display() - ), - None, - ) - })? - .to_str() - .ok_or_else(|| { - ErrorData::internal_error( - format!( - "Cannot convert basename to string for file {}", - path.display() - ), - None, - ) - })?; - - public_keys.push(String::from(basename)); - } - } - - Ok(ReadResourceResult { - contents: vec![ResourceContents::text( - public_keys.join(","), - request.uri.to_string(), - )], - }) - } - _ => { - return Err(ErrorData::invalid_request( - format!("Invalid path: {}", path_str), - None, - )); - } - } - } - _ => { - return Err(ErrorData::invalid_request( - format!( - "Invalid URI scheme. Supported schemes: file://, got: {}", - url.scheme() - ), - None, - )); - } - } - } -} - -/// Run a command on a remote POSIX compatible system (Linux, BSD, macOS) system -/// via SSH. -#[tracing::instrument] -async fn exec_ssh( - user: &str, - host: &str, - private_key: &str, - command: &str, - args: &[&str], - timeout_seconds: u64, - options: Option<&[&str]>, -) -> Result { - let _span = tracing::span!(tracing::Level::TRACE, "exec_ssh", user = %user, host = %host, private_key = %private_key, command = %command, args = ?args, timeout_seconds = %timeout_seconds); - let _enter = _span.enter(); - - let expanded_key = expand_tilde(private_key).map_err(|e| { - ErrorData::internal_error(format!("Failed to expand private key path: {}", e), None) - })?; - let private_key_path = expanded_key.deref().as_os_str().to_str().ok_or_else(|| { - ErrorData::internal_error( - format!("Failed to convert private key to string: {}", private_key), - None, - ) - })?; - - let command_future = Command::new("ssh") - .arg(host) - .args(["-l", user]) - .args(["-i", private_key_path]) - .arg(command) - .args(args) - .args( - options - .unwrap_or_default() - .iter() - .flat_map(|opt| ["-o", opt]), - ) - .output(); - - let result = if timeout_seconds == 0 { - // No timeout - run indefinitely - command_future.await - } else { - // Apply timeout - let timeout_duration = Duration::from_secs(timeout_seconds); - match timeout(timeout_duration, command_future).await { - Ok(result) => result, - Err(_) => { - return Err(ErrorData::internal_error( - format!("SSH command timed out after {} seconds", timeout_seconds), - None, - )); - } - } - }; - - result.map_err(|e| ErrorData::internal_error(format!("Failed to run SSH command: {}", e), None)) -} +pub mod handler; +pub mod tools; diff --git a/src/main.rs b/src/main.rs index b117a43..2237b37 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,14 @@ use anyhow::Error; use directories::ProjectDirs; -use mcp_linux_ssh::Handler; -use rmcp::{ServiceExt, transport::stdio}; +use mcp_linux_ssh::handler::POSIXSSHHandler; +use rust_mcp_sdk::{ + McpServer, StdioTransport, TransportOptions, + mcp_server::server_runtime, + schema::{ + Implementation, InitializeResult, LATEST_PROTOCOL_VERSION, ServerCapabilities, + ServerCapabilitiesTools, + }, +}; use std::fs::create_dir_all; use std::path::PathBuf; use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt}; @@ -52,14 +59,41 @@ async fn main() -> Result<(), Error> { .init(); tracing::info!("starting"); - let handler = Handler::new(); - let server = handler - .serve(stdio()) - .await - .inspect_err(|e| tracing::error!("Error: {:?}", e))?; - tracing::info!("started"); - server.waiting().await?; + // Define server details & capabilities + let server_details = InitializeResult { + server_info: Implementation { + name: env!("CARGO_PKG_NAME").to_string(), + title: Some("Linux SSH Administration".to_string()), + version: env!("CARGO_PKG_VERSION").to_string(), + }, + capabilities: ServerCapabilities { + tools: Some(ServerCapabilitiesTools { list_changed: None }), + ..Default::default() + }, + instructions: Some(String::from( + "You are an expert POSIX compatible system (Linux, BSD, macOS) system \ + administrator. You run commands on a remote POSIX compatible system \ + (Linux, BSD, macOS) system to troubleshoot, fix issues and perform \ + general administration.", + )), + meta: None, + protocol_version: LATEST_PROTOCOL_VERSION.to_string(), + }; + + // Create transport with default options + let transport = StdioTransport::new(TransportOptions::default()) + .map_err(|e| Error::msg(format!("{}", e)))?; - Ok(()) + // Create custom handler + let handler = POSIXSSHHandler {}; + + // Create Server + let server = server_runtime::create_server(server_details, transport, handler); + + // Start! + server + .start() + .await + .map_err(|e| Error::msg(format!("{}", e))) } diff --git a/src/tools/copy_file.rs b/src/tools/copy_file.rs new file mode 100644 index 0000000..3d99f5c --- /dev/null +++ b/src/tools/copy_file.rs @@ -0,0 +1,154 @@ +use expand_tilde::expand_tilde; +use rust_mcp_sdk::{ + macros::{JsonSchema, mcp_tool}, + schema::{CallToolResult, TextContent, schema_utils::CallToolError}, +}; +use std::ops::Deref; +use tokio::{ + process::Command, + time::{Duration, timeout}, +}; + +#[mcp_tool( + name = "copy_file", + description = "Copy a file from the local machine to a remote POSIX compatible system (Linux, BSD, macOS) using rsync over SSH. Preserves file attributes and creates a backup if the destination file already exists.", + title = "Copy File" +)] +#[derive(Debug, ::serde::Serialize, ::serde::Deserialize, JsonSchema)] +pub struct CopyFile { + /// The source file path on the local machine. + pub source: String, + /// The destination file path on the remote machine. + pub destination: String, + /// The user to run the command as. Defaults to the current username. + pub remote_user: Option, + /// The host to copy the file to. + pub remote_host: String, + /// The private key to use for authentication. Defaults to ~/.ssh/id_ed25519. + pub private_key: Option, + /// Timeout in seconds for the command execution. Defaults to 30 seconds. Set to 0 to disable timeout. + pub timeout_seconds: Option, +} + +impl CopyFile { + #[tracing::instrument(skip(self))] + pub async fn call_tool(&self) -> Result { + let _span = tracing::span!(tracing::Level::TRACE, "copy_file", source = ?self.source, destination = ?self.destination); + let _enter = _span.enter(); + + let source = expand_tilde(&self.source).map_err(|e| { + CallToolError::from_message(format!("Failed to expand source path: {}", e)) + })?; + + let remote_user = self.remote_user.clone().unwrap_or(whoami::username()); + let private_key = self + .private_key + .clone() + .unwrap_or("~/.ssh/id_ed25519".to_string()); + let timeout_seconds = self.timeout_seconds.unwrap_or(30); + + // Expand the private key path + let expanded_key = expand_tilde(&private_key).map_err(|e| { + CallToolError::from_message(format!("Failed to expand private key path: {}", e)) + })?; + let private_key_path = expanded_key.deref().as_os_str().to_str().ok_or_else(|| { + CallToolError::from_message(format!( + "Failed to convert private key to string: {}", + private_key + )) + })?; + + let ssh_command = format!("ssh -i {}", private_key_path); + let remote_target = format!("{}@{}:{}", remote_user, self.remote_host, self.destination); + + // Build the rsync command + // -a: archive mode (preserves permissions, timestamps, etc.) + // -v: verbose + // -b: create backups of existing files + // -e: specify ssh command with identity file + let command_future = Command::new("rsync") + .arg("-avb") + .arg("-e") + .arg(&ssh_command) + .arg(source.to_string_lossy().into_owned()) + .arg(&remote_target) + .output(); + + let result = if timeout_seconds == 0 { + // No timeout - run indefinitely + command_future.await + } else { + // Apply timeout + let timeout_duration = Duration::from_secs(timeout_seconds); + match timeout(timeout_duration, command_future).await { + Ok(result) => result, + Err(_) => { + return Err(CallToolError::from_message(format!( + "rsync command timed out after {} seconds", + timeout_seconds + ))); + } + } + }; + + match result { + Ok(output) => { + // The command executed successfully. This doesn't mean it + // succeeded, so output is returned as a successful tool call. + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let status_code = output.status.code(); + + Ok( + CallToolResult::text_content(vec![TextContent::from(stdout.clone())]) + .with_structured_content(super::map_from_output( + stdout, + stderr, + status_code, + )), + ) + } + Err(e) => Err(CallToolError::from_message(format!( + "Failed to execute rsync command: {}", + e + ))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_copy_file_struct_creation() { + let copy = CopyFile { + source: "/tmp/test.txt".to_string(), + destination: "/home/user/test.txt".to_string(), + remote_user: Some("testuser".to_string()), + remote_host: "localhost".to_string(), + private_key: Some("~/.ssh/test_key".to_string()), + timeout_seconds: Some(60), + }; + + assert_eq!(copy.source, "/tmp/test.txt"); + assert_eq!(copy.destination, "/home/user/test.txt"); + assert_eq!(copy.remote_host, "localhost"); + } + + #[test] + fn test_copy_file_defaults() { + let copy = CopyFile { + source: "file.txt".to_string(), + destination: "/remote/path/file.txt".to_string(), + remote_user: None, + remote_host: "example.com".to_string(), + private_key: None, + timeout_seconds: None, + }; + + assert!(copy.remote_user.is_none()); + assert!(copy.private_key.is_none()); + assert!(copy.timeout_seconds.is_none()); + } +} diff --git a/src/tools/local.rs b/src/tools/local.rs new file mode 100644 index 0000000..4ed1880 --- /dev/null +++ b/src/tools/local.rs @@ -0,0 +1,102 @@ +use rust_mcp_sdk::{ + macros::{JsonSchema, mcp_tool}, + schema::{CallToolResult, TextContent, schema_utils::CallToolError}, +}; +use tokio::{ + process::Command, + time::{Duration, timeout}, +}; + +#[mcp_tool( + name = "run_local_command", + description = "Run a command on the local system and return the output. Use this sparingly; only when needed to troubleshoot why connecting to the remote system is failing.", + title = "Run a local command" +)] +#[derive(Debug, ::serde::Deserialize, ::serde::Serialize, JsonSchema)] +pub struct RunLocalCommand { + /// The command to run. This must be a single command. Arguments must be passed in the args parameter. + cmd: String, + /// The arguments to pass to the command. + args: Vec, + /// Timeout in seconds for the command execution. Defaults to 30 seconds. Set to 0 to disable timeout. + timeout_seconds: Option, +} + +impl RunLocalCommand { + #[tracing::instrument(skip(self))] + pub async fn call_tool(&self) -> Result { + let _span = tracing::span!(tracing::Level::TRACE, "run_local_command", cmd = ?self.cmd, args = ?self.args, timeout_seconds = ?self.timeout_seconds); + let _enter = _span.enter(); + + let command_future = Command::new(&self.cmd).args(&self.args).output(); + + let result = if self.timeout_seconds == Some(0) { + // No timeout - run indefinitely + command_future.await + } else { + // Apply timeout + let timeout_duration = Duration::from_secs(self.timeout_seconds.unwrap_or(30)); + match timeout(timeout_duration, command_future).await { + Ok(result) => result, + Err(_) => { + return Err(CallToolError::from_message(format!( + "Local command timed out after {:?} seconds", + self.timeout_seconds + ))); + } + } + }; + + match result { + Ok(output) => { + // The command executed successfully. This doesn't mean it + // succeeded, so output is returned as a successful tool call. + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let status_code = output.status.code(); + + Ok( + CallToolResult::text_content(vec![TextContent::from(stdout.clone())]) + .with_structured_content(super::map_from_output( + stdout, + stderr, + status_code, + )), + ) + } + Err(err) => Err(CallToolError::from_message(format!( + "Failed to run local command: {}", + err + ))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_run_local_command_success() { + let cmd = RunLocalCommand { + cmd: "echo".to_string(), + args: vec!["hello".to_string()], + timeout_seconds: None, + }; + + let result = cmd.call_tool().await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_run_local_command_nonexistent() { + let cmd = RunLocalCommand { + cmd: "nonexistent_command_12345".to_string(), + args: vec![], + timeout_seconds: None, + }; + + let result = cmd.call_tool().await; + assert!(result.is_err()); + } +} diff --git a/src/tools/mod.rs b/src/tools/mod.rs new file mode 100644 index 0000000..3648a96 --- /dev/null +++ b/src/tools/mod.rs @@ -0,0 +1,39 @@ +mod copy_file; +mod local; +mod patch_file; +mod ssh; + +use copy_file::CopyFile; +use local::RunLocalCommand; +use patch_file::PatchFile; +use rust_mcp_sdk::tool_box; +use ssh::{RunSSHCommand, RunSSHSudoCommand}; + +tool_box!( + POSIXSSHTools, + [ + RunLocalCommand, + RunSSHCommand, + RunSSHSudoCommand, + CopyFile, + PatchFile + ] +); + +fn map_from_output( + stdout: String, + stderr: String, + status_code: Option, +) -> serde_json::Map { + let mut structured_content = serde_json::Map::new(); + structured_content.insert("stdout".to_string(), serde_json::Value::String(stdout)); + structured_content.insert("stderr".to_string(), serde_json::Value::String(stderr)); + structured_content.insert( + "status_code".to_string(), + match status_code { + Some(code) => serde_json::Value::Number(code.into()), + None => serde_json::Value::Null, + }, + ); + structured_content +} diff --git a/src/tools/patch_file.rs b/src/tools/patch_file.rs new file mode 100644 index 0000000..62b13d9 --- /dev/null +++ b/src/tools/patch_file.rs @@ -0,0 +1,183 @@ +use expand_tilde::expand_tilde; +use rust_mcp_sdk::{ + macros::{JsonSchema, mcp_tool}, + schema::{CallToolResult, TextContent, schema_utils::CallToolError}, +}; +use std::ops::Deref; +use tokio::{ + io::AsyncWriteExt, + process::Command, + time::{Duration, timeout}, +}; + +#[mcp_tool( + name = "patch_file", + description = "Apply a patch or diff to a file on the remote machine using the patch command. \ + The patch content is streamed via stdin over SSH. By default, patch will attempt to \ + automatically detect the correct strip level (-p). Use unified diff format for best results.", + title = "Patch File" +)] +#[derive(Debug, ::serde::Serialize, ::serde::Deserialize, JsonSchema)] +pub struct PatchFile { + /// The patch/diff content to apply. + pub patch: String, + /// The path to the file on the remote machine to patch. + pub remote_file: String, + /// The user to run the command as. Defaults to the current username. + pub remote_user: Option, + /// The host to run the command on. + pub remote_host: String, + /// The private key to use for authentication. Defaults to ~/.ssh/id_ed25519. + pub private_key: Option, + /// Timeout in seconds for the command execution. Defaults to 30 seconds. Set to 0 to disable timeout. + pub timeout_seconds: Option, + /// Additional options to pass to the ssh command. Each option should be a key-value pair separated by an equal sign (=). The options are passed to the ssh command using the -o flag. + pub options: Option>, +} + +impl PatchFile { + #[tracing::instrument(skip(self))] + pub async fn call_tool(&self) -> Result { + let _span = + tracing::span!(tracing::Level::TRACE, "patch_file", remote_file = ?self.remote_file); + let _enter = _span.enter(); + + let remote_user = self.remote_user.clone().unwrap_or(whoami::username()); + let private_key = self + .private_key + .clone() + .unwrap_or("~/.ssh/id_ed25519".to_string()); + let timeout_seconds = self.timeout_seconds.unwrap_or(30); + let options_vec: Option> = self + .options + .as_ref() + .map(|v| v.iter().map(String::as_str).collect()); + + // Expand the private key path + let expanded_key = expand_tilde(&private_key).map_err(|e| { + CallToolError::from_message(format!("Failed to expand private key path: {}", e)) + })?; + let private_key_path = expanded_key.deref().as_os_str().to_str().ok_or_else(|| { + CallToolError::from_message(format!( + "Failed to convert private key to string: {}", + private_key + )) + })?; + + // Build SSH command that will run patch on the remote side + // The patch command reads from stdin and applies to the specified file + let mut cmd = Command::new("ssh"); + cmd.arg(&self.remote_host) + .args(["-l", &remote_user]) + .args(["-i", private_key_path]) + .args( + options_vec + .unwrap_or_default() + .iter() + .flat_map(|opt| ["-o", opt]), + ) + .arg("patch") + .arg(&self.remote_file) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + + let command_future = async { + let mut child = cmd.spawn().map_err(|e| { + CallToolError::from_message(format!("Failed to spawn SSH command: {}", e)) + })?; + + // Write the patch content to stdin + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(self.patch.as_bytes()).await.map_err(|e| { + CallToolError::from_message(format!("Failed to write patch to stdin: {}", e)) + })?; + // Close stdin to signal EOF + drop(stdin); + } + + // Wait for the command to complete + child.wait_with_output().await.map_err(|e| { + CallToolError::from_message(format!("Failed to wait for SSH command: {}", e)) + }) + }; + + let result = if timeout_seconds == 0 { + // No timeout - run indefinitely + command_future.await + } else { + // Apply timeout + let timeout_duration = Duration::from_secs(timeout_seconds); + match timeout(timeout_duration, command_future).await { + Ok(result) => result, + Err(_) => { + return Err(CallToolError::from_message(format!( + "Patch command timed out after {} seconds", + timeout_seconds + ))); + } + } + }; + + match result { + Ok(output) => { + // The command executed successfully. This doesn't mean it + // succeeded, so output is returned as a successful tool call. + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let status_code = output.status.code(); + + Ok( + CallToolResult::text_content(vec![TextContent::from(stdout.clone())]) + .with_structured_content(super::map_from_output( + stdout, + stderr, + status_code, + )), + ) + } + Err(e) => Err(e), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_patch_file_struct_creation() { + let patch_cmd = PatchFile { + patch: "--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old\n+new".to_string(), + remote_file: "/home/user/file.txt".to_string(), + remote_user: Some("testuser".to_string()), + remote_host: "localhost".to_string(), + private_key: Some("~/.ssh/test_key".to_string()), + timeout_seconds: Some(60), + options: Some(vec!["StrictHostKeyChecking=no".to_string()]), + }; + + assert_eq!(patch_cmd.remote_file, "/home/user/file.txt"); + assert_eq!(patch_cmd.remote_host, "localhost"); + assert!(patch_cmd.patch.contains("old")); + assert!(patch_cmd.patch.contains("new")); + } + + #[test] + fn test_patch_file_defaults() { + let patch_cmd = PatchFile { + patch: "diff content".to_string(), + remote_file: "/path/to/file".to_string(), + remote_user: None, + remote_host: "example.com".to_string(), + private_key: None, + timeout_seconds: None, + options: None, + }; + + assert!(patch_cmd.remote_user.is_none()); + assert!(patch_cmd.private_key.is_none()); + assert!(patch_cmd.timeout_seconds.is_none()); + assert!(patch_cmd.options.is_none()); + } +} diff --git a/src/tools/ssh.rs b/src/tools/ssh.rs new file mode 100644 index 0000000..ceb7658 --- /dev/null +++ b/src/tools/ssh.rs @@ -0,0 +1,275 @@ +use anyhow::Error; +use expand_tilde::expand_tilde; +use rust_mcp_sdk::{ + macros::{JsonSchema, mcp_tool}, + schema::{CallToolResult, TextContent, schema_utils::CallToolError}, +}; +use std::ops::Deref; +use tokio::{ + process::Command, + time::{Duration, timeout}, +}; + +#[mcp_tool( + name = "run_ssh_command", + description = "Run a command on a remote POSIX compatible system (Linux, BSD, macOS) system and return the output. This tool does not permit commands to be run with sudo.", + title = "Run SSH Command" +)] +#[derive(Debug, ::serde::Serialize, ::serde::Deserialize, JsonSchema)] +pub struct RunSSHCommand { + /// The user to run the command as. Defaults to the current username. + pub remote_user: Option, + /// The host to run the command on. + pub remote_host: String, + /// The private key to use for authentication. Defaults to ~/.ssh/id_ed25519. + pub private_key: Option, + /// The command to run. This must be a single command. Arguments must be passed in the args parameter. + pub cmd: String, + /// The arguments to pass to the command. + pub args: Vec, + /// Timeout in seconds for the command execution. Defaults to 30 seconds. Set to 0 to disable timeout. + pub timeout_seconds: Option, + /// Additional options to pass to the ssh command. Each option should be a key-value pair separated by an equal sign (=). The options are passed to the ssh command using the -o flag. + pub options: Option>, +} + +impl RunSSHCommand { + #[tracing::instrument(skip(self))] + pub async fn call_tool(&self) -> Result { + let _span = tracing::span!(tracing::Level::TRACE, "run_ssh_command", cmd = ?self.cmd, args = ?self.args, timeout_seconds = ?self.timeout_seconds); + let _enter = _span.enter(); + + let remote_user = self.remote_user.clone().unwrap_or(whoami::username()); + let private_key = self + .private_key + .clone() + .unwrap_or("~/.ssh/id_ed25519".to_string()) + .to_string(); + let timeout_seconds = self.timeout_seconds.unwrap_or(30); + let options_vec: Option> = self + .options + .as_ref() + .map(|v| v.iter().map(String::as_str).collect()); + + if self.cmd.contains("sudo") || self.args.iter().any(|arg| arg.contains("sudo")) { + // sudo is not permitted for this tool. + return Err(CallToolError::from_message( + "You may not run commands with sudo using this tool", + )); + } + + match exec_ssh( + &remote_user, + &self.remote_host, + &private_key, + &self.cmd, + &self + .args + .iter() + .map(|arg| arg.as_str()) + .collect::>(), + timeout_seconds, + options_vec.as_deref(), + ) + .await + { + Ok(output) => { + // The command executed successfully. This doesn't mean it + // succeeded, so output is returned as a successful tool call. + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let status_code = output.status.code(); + + Ok( + CallToolResult::text_content(vec![TextContent::from(stdout.clone())]) + .with_structured_content(super::map_from_output( + stdout, + stderr, + status_code, + )), + ) + } + Err(err) => Err(CallToolError::from_message(format!( + "Failed to execute remote SSH command: {}", + err + ))), + } + } +} + +#[mcp_tool( + name = "run_ssh_sudo_command", + description = "Run a command on a remote POSIX compatible system (Linux, \ + BSD, macOS) system and return the output. This tool explicitly runs \ + commands with sudo.", + title = "Run SSH Sudo Command" +)] +#[derive(Debug, ::serde::Serialize, ::serde::Deserialize, JsonSchema)] +pub struct RunSSHSudoCommand { + /// The user to run the command as. Defaults to the current username. + pub remote_user: Option, + /// The host to run the command on. + pub remote_host: String, + /// The private key to use for authentication. Defaults to ~/.ssh/id_ed25519. + pub private_key: Option, + /// The command to run. This must be a single command. Arguments must be passed in the args parameter. + pub cmd: String, + /// The arguments to pass to the command. + pub args: Vec, + /// Timeout in seconds for the command execution. Defaults to 30 seconds. Set to 0 to disable timeout. + pub timeout_seconds: Option, + /// Additional options to pass to the ssh command. Each option should be a key-value pair separated by an equal sign (=). The options are passed to the ssh command using the -o flag. + pub options: Option>, +} + +impl RunSSHSudoCommand { + #[tracing::instrument(skip(self))] + pub async fn call_tool(&self) -> Result { + let _span = tracing::span!(tracing::Level::TRACE, "run_ssh_sudo_command", cmd = ?self.cmd, args = ?self.args, timeout_seconds = ?self.timeout_seconds); + let _enter = _span.enter(); + + let remote_user = self.remote_user.clone().unwrap_or(whoami::username()); + let private_key = self + .private_key + .clone() + .unwrap_or("~/.ssh/id_ed25519".to_string()) + .to_string(); + let timeout_seconds = self.timeout_seconds.unwrap_or(30); + let options_vec: Option> = self + .options + .as_ref() + .map(|v| v.iter().map(String::as_str).collect()); + + match exec_ssh( + &remote_user, + &self.remote_host, + &private_key, + "sudo", + std::iter::once(self.cmd.as_str()) + .chain(self.args.iter().map(|arg| arg.as_str())) + .collect::>() + .as_slice(), + timeout_seconds, + options_vec.as_deref(), + ) + .await + { + Ok(output) => { + // The command executed successfully. This doesn't mean it + // succeeded, so output is returned as a successful tool call. + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let status_code = output.status.code(); + + Ok( + CallToolResult::text_content(vec![TextContent::from(stdout.clone())]) + .with_structured_content(super::map_from_output( + stdout, + stderr, + status_code, + )), + ) + } + Err(err) => Err(CallToolError::from_message(format!( + "Failed to execute remote SSH command with sudo: {}", + err + ))), + } + } +} + +/// Run a command on a remote POSIX compatible system (Linux, BSD, macOS) system +/// via SSH. +#[tracing::instrument] +async fn exec_ssh( + user: &str, + host: &str, + private_key: &str, + command: &str, + args: &[&str], + timeout_seconds: u64, + options: Option<&[&str]>, +) -> Result { + let _span = tracing::span!(tracing::Level::TRACE, "exec_ssh", user = %user, host = %host, private_key = %private_key, command = %command, args = ?args, timeout_seconds = %timeout_seconds); + let _enter = _span.enter(); + + let expanded_key = expand_tilde(private_key) + .map_err(|e| Error::msg(format!("Failed to expand private key path: {}", e)))?; + let private_key_path = expanded_key.deref().as_os_str().to_str().ok_or_else(|| { + Error::msg(format!( + "Failed to convert private key to string: {}", + private_key + )) + })?; + + let command_future = Command::new("ssh") + .arg(host) + .args(["-l", user]) + .args(["-i", private_key_path]) + .arg(command) + .args(args) + .args( + options + .unwrap_or_default() + .iter() + .flat_map(|opt| ["-o", opt]), + ) + .output(); + + let result = if timeout_seconds == 0 { + // No timeout - run indefinitely + command_future.await + } else { + // Apply timeout + let timeout_duration = Duration::from_secs(timeout_seconds); + match timeout(timeout_duration, command_future).await { + Ok(result) => result, + Err(_) => { + return Err(Error::msg(format!( + "SSH command timed out after {} seconds", + timeout_seconds + ))); + } + } + }; + + result.map_err(|e| Error::msg(format!("Failed to run SSH command: {}", e))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_run_ssh_command_rejects_sudo() { + let cmd = RunSSHCommand { + remote_user: None, + remote_host: "localhost".to_string(), + private_key: None, + cmd: "sudo".to_string(), + args: vec!["ls".to_string()], + timeout_seconds: Some(1), + options: None, + }; + + let result = cmd.call_tool().await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("sudo")); + } + + #[test] + fn test_run_ssh_sudo_command_struct_creation() { + let cmd = RunSSHSudoCommand { + remote_user: Some("testuser".to_string()), + remote_host: "localhost".to_string(), + private_key: Some("~/.ssh/test_key".to_string()), + cmd: "apt".to_string(), + args: vec!["update".to_string()], + timeout_seconds: Some(60), + options: None, + }; + + assert_eq!(cmd.remote_host, "localhost"); + assert_eq!(cmd.cmd, "apt"); + } +}