From 7e4225d3185528597a4dc4305536c4dc80615a8d Mon Sep 17 00:00:00 2001
From: Link Dupont
Date: Thu, 9 Oct 2025 14:36:51 -0400
Subject: [PATCH 1/2] Convert from rmcp to rust-mcp-sdk
Each tool now lives in a separate module. This approach scales better
than the rmcp approach of putting all tools on one struct.
---
Cargo.lock | 754 ++++++++++------------------------------
Cargo.toml | 11 +-
src/handler.rs | 44 +++
src/lib.rs | 732 --------------------------------------
src/main.rs | 57 ++-
src/tools/copy_file.rs | 154 ++++++++
src/tools/local.rs | 102 ++++++
src/tools/mod.rs | 39 +++
src/tools/patch_file.rs | 183 ++++++++++
src/tools/ssh.rs | 275 +++++++++++++++
10 files changed, 1030 insertions(+), 1321 deletions(-)
create mode 100644 src/handler.rs
delete mode 100644 src/lib.rs
create mode 100644 src/tools/copy_file.rs
create mode 100644 src/tools/local.rs
create mode 100644 src/tools/mod.rs
create mode 100644 src/tools/patch_file.rs
create mode 100644 src/tools/ssh.rs
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
deleted file mode 100644
index 0e37bd3..0000000
--- a/src/lib.rs
+++ /dev/null
@@ -1,732 +0,0 @@
-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))
-}
diff --git a/src/main.rs b/src/main.rs
index b117a43..62b106b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,7 +1,17 @@
+mod handler;
+mod tools;
+
use anyhow::Error;
use directories::ProjectDirs;
-use mcp_linux_ssh::Handler;
-use rmcp::{ServiceExt, transport::stdio};
+use 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 +62,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");
+ }
+}
From 0069a38f8ad2f2865c23d4e6dc9b085a87828f4a Mon Sep 17 00:00:00 2001
From: Link Dupont
Date: Thu, 9 Oct 2025 21:14:02 -0400
Subject: [PATCH 2/2] Define a library crate for the tools and handlers
---
src/lib.rs | 2 ++
src/main.rs | 5 +----
2 files changed, 3 insertions(+), 4 deletions(-)
create mode 100644 src/lib.rs
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..cee96e4
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,2 @@
+pub mod handler;
+pub mod tools;
diff --git a/src/main.rs b/src/main.rs
index 62b106b..2237b37 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,9 +1,6 @@
-mod handler;
-mod tools;
-
use anyhow::Error;
use directories::ProjectDirs;
-use handler::POSIXSSHHandler;
+use mcp_linux_ssh::handler::POSIXSSHHandler;
use rust_mcp_sdk::{
McpServer, StdioTransport, TransportOptions,
mcp_server::server_runtime,