diff --git a/.github/workflows/mcp-bashserver-test.yml b/.github/workflows/mcp-bashserver-test.yml new file mode 100644 index 00000000000..6991aac9d19 --- /dev/null +++ b/.github/workflows/mcp-bashserver-test.yml @@ -0,0 +1,97 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +name: MCP Bash Server CI + +on: + push: + paths: + - 'mcp-servers/mcp-bash-server/**' + - '.github/workflows/mcp-bashserver-test.yml' + pull_request: + paths: + - 'mcp-servers/mcp-bash-server/**' + - '.github/workflows/mcp-bashserver-test.yml' + +env: + CARGO_TERM_COLOR: always + RUST_VERSION: 1.88.0 + +jobs: + test: + name: Test MCP Bash Server + runs-on: ubuntu-latest + strategy: + matrix: + profile: [dev, release] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_VERSION }} + components: rustfmt, clippy + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-registry- + + - name: Cache cargo build + uses: actions/cache@v4 + with: + path: target/ + key: ${{ runner.os }}-cargo-build-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-build-${{ matrix.profile }}- + ${{ runner.os }}-cargo-build- + + - name: Check code format + run: cargo fmt --manifest-path ./mcp-servers/mcp-bash-server/Cargo.toml --check + + - name: Run cargo check + run: | + if [ "${{ matrix.profile }}" = "release" ]; then + cargo check --release --manifest-path ./mcp-servers/mcp-bash-server/Cargo.toml + else + cargo check --manifest-path ./mcp-servers/mcp-bash-server/Cargo.toml + fi + + - name: Run clippy + run: | + if [ "${{ matrix.profile }}" = "release" ]; then + cargo clippy --release --manifest-path ./mcp-servers/mcp-bash-server/Cargo.toml -- -D warnings + else + cargo clippy --manifest-path ./mcp-servers/mcp-bash-server/Cargo.toml -- -D warnings + fi + + - name: Run tests + run: | + if [ "${{ matrix.profile }}" = "release" ]; then + cargo test --release --manifest-path ./mcp-servers/mcp-bash-server/Cargo.toml + else + cargo test --manifest-path ./mcp-servers/mcp-bash-server/Cargo.toml + fi diff --git a/mcp-servers/README.md b/mcp-servers/README.md new file mode 100644 index 00000000000..b31465b5a54 --- /dev/null +++ b/mcp-servers/README.md @@ -0,0 +1,13 @@ +# Introduction +This file has a short description of each mcp server in `mcp-servers` directory + +# MCP servers + +## mcp-bash-server + +This mcp server provide the ability to execute shell command or ASCII text executable +script in the machine running this server. + +Use a simple Oauth2.0 for authentication. + + diff --git a/mcp-servers/mcp-bash-server/.gitignore b/mcp-servers/mcp-bash-server/.gitignore new file mode 100644 index 00000000000..232ed824ad4 --- /dev/null +++ b/mcp-servers/mcp-bash-server/.gitignore @@ -0,0 +1,2 @@ +/target +logs/ diff --git a/mcp-servers/mcp-bash-server/Cargo.lock b/mcp-servers/mcp-bash-server/Cargo.lock new file mode 100644 index 00000000000..f4a3287d686 --- /dev/null +++ b/mcp-servers/mcp-bash-server/Cargo.lock @@ -0,0 +1,2813 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[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.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" + +[[package]] +name = "askama" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4" +dependencies = [ + "askama_derive", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f" +dependencies = [ + "askama_parser", + "basic-toml", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn", +] + +[[package]] +name = "askama_parser" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358" +dependencies = [ + "memchr", + "serde", + "serde_derive", + "winnow", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.88" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "auto-future" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c1e7e457ea78e524f48639f551fd79703ac3f2237f5ecccdf4708f8a75ad373" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core 0.4.5", + "bytes", + "futures-util", + "http 1.3.1", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit 0.7.3", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower 0.5.2", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" +dependencies = [ + "axum-core 0.5.2", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http 1.3.1", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit 0.8.4", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower 0.5.2", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 1.3.1", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" +dependencies = [ + "bytes", + "futures-core", + "http 1.3.1", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "axum-test" +version = "14.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167294800740b4b6bc7bfbccbf3a1d50a6c6e097342580ec4c11d1672e456292" +dependencies = [ + "anyhow", + "async-trait", + "auto-future", + "axum 0.7.9", + "bytes", + "cookie", + "http 1.3.1", + "http-body-util", + "hyper", + "hyper-util", + "mime", + "pretty_assertions", + "reserve-port", + "rust-multipart-rfc7578_2", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "tokio", + "tower 0.4.13", + "url", +] + +[[package]] +name = "backtrace" +version = "0.3.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cc" +version = "1.2.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a79c4acb1fd5fa3d9304be4c76e031c54d2e92d172a393e24b19a14fe8532fe9" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74875de90daf30eb59609910b84d4d368103aaec4c924824c6799b28f77d6a1d" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79f8e61677d5df9167cd85265f8e5f64b215cdea3fb55eebc3e622e44c7a146" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[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.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "hashbrown" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.3.1", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.3.1", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.3.1", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http 1.3.1", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66d5bd4c6f02bf0542fad85d626775bab9258cf795a4256dcaf3161114d1df" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http 1.3.1", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "io-uring" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.174" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "lock_api" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "mcp-bash-server" +version = "0.1.0" +dependencies = [ + "anyhow", + "askama", + "axum 0.8.4", + "axum-test", + "chrono", + "hyper", + "oauth2", + "rand 0.8.5", + "regex", + "rmcp", + "serde", + "serde_urlencoded", + "shlex", + "tempfile", + "tokio", + "tokio-stream", + "tokio-test", + "tokio-util", + "toml", + "tower 0.5.2", + "tower-http", + "tracing", + "tracing-appender", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", +] + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "num-conv" +version = "0.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 = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64", + "chrono", + "getrandom 0.2.16", + "http 1.3.1", + "rand 0.8.5", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "parking_lot" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "potential_utf" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626214629cda6781b6dc1d316ba307189c85ba657213ce642d9c77670f8202c8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.12", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e" +dependencies = [ + "bytes", + "getrandom 0.3.3", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.12", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcebb1209ee276352ef14ff8732e24cc2b02bbac986cd74a4c81bcb2f9881970" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +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 = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", +] + +[[package]] +name = "redox_syscall" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8af0dde094006011e6a740d4879319439489813bd0bcdc7d821beaeeff48ec" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "reqwest" +version = "0.12.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http 1.3.1", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower 0.5.2", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reserve-port" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21918d6644020c6f6ef1993242989bf6d4952d2e025617744f184c02df51c356" +dependencies = [ + "thiserror 2.0.12", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmcp" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a04b2d9da174d72bc0511410242eb3e8f47a02d9e23868e4b076c7c70208eb4" +dependencies = [ + "axum 0.8.4", + "base64", + "bytes", + "chrono", + "futures", + "http 1.3.1", + "http-body", + "http-body-util", + "oauth2", + "paste", + "pin-project-lite", + "rand 0.9.2", + "reqwest", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.12", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "rmcp-macros" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "651b4292f292d4a4ba191e64f0ce553aef5455b8ddf831dc6a8cd328dd68d721" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn", +] + +[[package]] +name = "rust-multipart-rfc7578_2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03b748410c0afdef2ebbe3685a6a862e2ee937127cdaae623336a459451c8d57" +dependencies = [ + "bytes", + "futures-core", + "futures-util", + "http 0.2.12", + "mime", + "mime_guess", + "rand 0.8.5", + "thiserror 1.0.69", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls" +version = "0.23.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2491382039b29b9b11ff08b76ff6c97cf287671dbb74f0be44bda389fffe9bd1" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "schemars" +version = "1.0.4" +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", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +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" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.141" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" +dependencies = [ + "itoa", + "serde", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +dependencies = [ + "libc", +] + +[[package]] +name = "slab" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "sse-stream" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb4dc4d33c68ec1f27d386b5610a351922656e1fdf5c05bbaad930cd1519479a" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[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 = "tempfile" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +dependencies = [ + "fastrand", + "getrandom 0.3.3", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl 2.0.12", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" + +[[package]] +name = "time-macros" +version = "0.2.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +dependencies = [ + "num-conv", + "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 = "tinyvec" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.46.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17" +dependencies = [ + "backtrace", + "bytes", + "io-uring", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "slab", + "socket2", + "tokio-macros", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-test" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" +dependencies = [ + "async-stream", + "bytes", + "futures-core", + "tokio", + "tokio-stream", +] + +[[package]] +name = "tokio-util" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "futures-util", + "hashbrown", + "pin-project-lite", + "slab", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http 1.3.1", + "http-body", + "iri-string", + "pin-project-lite", + "tower 0.5.2", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf" +dependencies = [ + "crossbeam-channel", + "thiserror 1.0.69", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" +dependencies = [ + "getrandom 0.3.3", + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +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.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +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.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags", +] + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[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 = "zerocopy" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +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", +] diff --git a/mcp-servers/mcp-bash-server/Cargo.toml b/mcp-servers/mcp-bash-server/Cargo.toml new file mode 100644 index 00000000000..f4e96235294 --- /dev/null +++ b/mcp-servers/mcp-bash-server/Cargo.toml @@ -0,0 +1,56 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +[package] +name = "mcp-bash-server" +version = "0.1.0" +edition = "2024" + +[dependencies] +tracing-appender = "0.2" +anyhow = "1.0.98" +rmcp = { version = "0.4.0", features = ["server", "transport-sse-server", "transport-io", "transport-streamable-http-server", "auth"]} +serde = { version = "1.0", features = ["derive"] } +tokio = { version = "1.0", features = ["full"] } +tokio-stream = { version = "0.1", features = ["net"] } +tokio-util = { version = "0.7.15", features = ["full"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = [ + "env-filter", + "std", + "fmt", +] } +axum = { version = "0.8", features = ["macros"] } +chrono = "0.4" +tower-http = { version = "0.6", features = ["cors"] } +askama = { version = "0.14"} +rand = { version = "0.8", features = ["std"] } +uuid = { version = "1.6", features = ["v4", "serde"] } +serde_urlencoded = "0.7" +oauth2 = "5.0" +toml = "0.8" +regex = "1.11.1" +shlex = "1.3" + +[dev-dependencies] +tokio-test = "0.4" +tempfile = "3.0" +tower = "0.5" +hyper = "1.0" +axum-test = "14.1" + +[[bin]] +name = "mcp-bash-server" +path = "src/main.rs" diff --git a/mcp-servers/mcp-bash-server/Dockerfile b/mcp-servers/mcp-bash-server/Dockerfile new file mode 100644 index 00000000000..5479020b469 --- /dev/null +++ b/mcp-servers/mcp-bash-server/Dockerfile @@ -0,0 +1,97 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# Multi-stage build for mcp-bash-server +# Stage 1: Build stage +FROM ubuntu:noble AS builder + +# Set environment variables +ENV DEBIAN_FRONTEND=noninteractive +ENV RUST_VERSION=stable +ENV PATH="/root/.cargo/bin:${PATH}" + +# Set proxy environment variables via build arguments +ARG HTTPS_PROXY +ARG HTTP_PROXY +ENV https_proxy=${HTTPS_PROXY} +ENV http_proxy=${HTTP_PROXY} + +# Install build dependencies +RUN apt-get update && apt-get install -y \ + curl \ + build-essential \ + pkg-config \ + libssl-dev \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Install Rust +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain ${RUST_VERSION} + +# Set working directory +WORKDIR /app + +# Copy the mcp-bash-server project +COPY mcp-bash-server/ . + +# Build the project in release mode +RUN cargo build --release + +# Stage 2: Runtime stage +FROM ubuntu:noble AS runtime + +# Set environment variables +ENV DEBIAN_FRONTEND=noninteractive + +# Install runtime dependencies +RUN apt-get update && apt-get install -y \ + ca-certificates \ + libssl3 \ + bash \ + coreutils \ + procps \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Create a non-root user for security +RUN useradd -m -s /bin/bash mcpuser + +# Set working directory +WORKDIR /app + +# Copy the compiled binary from builder stage +COPY --from=builder /app/target/release/mcp-bash-server /app/mcp-bash-server + +# Copy configuration and templates +COPY --from=builder /app/config.toml /app/config.toml +COPY --from=builder /app/templates/ /app/templates/ + +# Create logs directory and set permissions +RUN mkdir -p logs && chown -R mcpuser:mcpuser /app + +# Switch to non-root user +USER mcpuser + +# Expose the port +EXPOSE 4000 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:4000/ || exit 1 + +# Set the entrypoint to run the mcp-bash-server +CMD ["./mcp-bash-server"] diff --git a/mcp-servers/mcp-bash-server/README.md b/mcp-servers/mcp-bash-server/README.md new file mode 100644 index 00000000000..06172f70c56 --- /dev/null +++ b/mcp-servers/mcp-bash-server/README.md @@ -0,0 +1,333 @@ +# mcp-bash-server + +A HertzBeat MCP server for running scripts with security command blacklist and logging capabilities + +## Dependencies + +### Rust + +If you need to deploy this MCP Server locally, you will need a Rust runtime environment. + +Visit [rust-lang.org](https://www.rust-lang.org/tools/install) to learn how to install the Rust runtime environment. + +Version `1.88.0` can absolutely work, and we recommend using the latest version of Rust. + +## Deployment + +### Local Deployment + +If you want to run this MCP server locally using the default settings provided by the project, simply run the following command in the project root directory: + +```Rust +cargo run +``` + +This MCP server will be deployed at `http://127.0.0.1:4000/mcp`, and you can use the `modelcontextprotocol/inspector` tool to connect to and use this MCP server. + +For information on how to use the modelcontextprotocol/inspector tool, refer to the [inspector documentation](https://github.com/modelcontextprotocol/inspector). + +If you encounter any issues while using Inspector, it is recommended to use version `v0.16.2`. Other versions may also work. + +### Container Deployment + +Using container deployment for this MCP Server is an excellent way to try out the tools provided by the server without polluting your machine, as all MCP Server operations are completed within the container. + +Refer to the `Dockerfile` in the code repository and create your own Dockerfile. After creating it, run the following build command: + +```shell +docker build -t apache/hertzbeat-mcp-bash-server:latest . +``` + +You can use the proxy in docker build + +```shell +docker build --build-arg HTTPS_PROXY= --build-arg HTTP_PROXY= -t apache/hertzbeat-mcp-bash-server:latest . +``` + +After building, use the following command to run it: + +```shell +docker run -d --name mcp-bash-server -p 4000:4000 --restart unless-stopped apache/hertzbeat-mcp-bash-server:latest +``` + +The MCP Server inside the container runs on 0.0.0.0:4000. On the host machine, use the inspector with URL `http://localhost:4000/mcp` to connect to the MCP Server inside the container. + +#### Use custom config in container + +Container's workdir is `/app` and it will run the `/app/mcp-bash-server` when it start, this program will read the `config.toml` at the same directory, so you can put the `config.toml` in the `/app` directory to cover the default config in image. Use the command below to do it. + +```shell +docker run -d --name mcp-bash-server -p 4000:4000 -v `pwd`/config.toml:/app/config.toml --restart unless-stopped apache/hertzbeat-mcp-bash-server:latest +``` + +If you are using SELinux, you may need to run the command instead to let the container access the file in host. + +```shell +docker run -d --name mcp-bash-server -p 4000:4000 -v `pwd`/config.toml:/app/config.toml:Z --restart unless-stopped apache/hertzbeat-mcp-bash-server:latest +``` + +To check if the config.toml is used, do this + +```shell +docker logs mcp-bash-server +``` + +or check the config.toml in container + +```shell +docker exec mcp-bash-server ls /app +docker exec mcp-bash-server cat /app/config.toml +``` + +## Use it by Agents + +### Vscode Copilot + +Start the MCP Server in daemon mode, then add the settings to your Vscode Copilot mcp config. Note that you can use modelcontextprotocol/inspector to finish the OAuth flow and get the access token, put it in config and restart server, then Copilot can connect to the server and use the tools. + +```json +{ + "servers": { + "bash-server": { + "url": "http://localhost:4000/mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` + +**Currently Vscode MCP OAuth can not automatically authorize this bash-server** +The vscode mcp OAuth flow is: + +1. GET /.well-known/oauth-authorization-server +2. GET /authorize with query-params +3. ... + +But we requires the client registration before accessing endpoint `/authorize` with query-params that contains invalid client-id. So we can only set the token manually now. + +## Configuration + +The `config.toml` file contains the settings for this MCP server. The configuration items currently supported by the configuration file are as follows: + +```toml +# This is the configuration file for mcp-bash-server + +[settings] +# Port for MCP Server deployment +port = 4000 +# IP for MCP Server deployment +host = "127.0.0.1" +# Usage environment for MCP Server, can be either development or production environment. +# Set env to "development" or "production"; production uses OAuth 2.0. +env = "development" + +[whitelist] +# Whitelist of allowed commands (exact match), a string list. +# Only commands where the complete command string exactly matches an item in this list will be allowed +commands = [ + "echo hello", + "ls -la", + "pwd", + # Add your allowed commands here +] +# Whitelist of allowed command regex patterns, a list of regex expression strings +# Commands where the complete command string matches any of these regex patterns will be allowed +regex = [ + '^echo [a-zA-Z0-9 ]+$', + '^ls [a-zA-Z0-9 /-]*$', + # Add your whitelist regex patterns here +] + +[blacklist] +# Blacklist of forbidden commands (exact match), a string list +# Blacklist has higher priority than whitelist. If the complete command string exactly matches an item in this list, the entire command will be blocked, even if it would be allowed by the whitelist +# Note: Only exact matches are blocked. For example, if "rm" is blacklisted, only the exact command "rm" is blocked, not commands like "rm -rf /tmp/test" +commands = [ + # Dangerous file operations + "rm -rf /", + "shutdown", + # Add your forbidden commands here +] +# Blacklist of forbidden command regex patterns, a list of regex expression strings +# Blacklist has higher priority than whitelist. If the complete command string matches any of these regex patterns, it will be blocked, even if it would be allowed by the whitelist +regex = [ + # Block any command with dangerous operators + '.*[|&;`$()><].*', + # Block commands that try to write to system directories + '.*/etc/.*', + '.*/root/.*', + # Block commands with sudo or su + '^sudo .*', + '^su .*', + # Add your blacklist regex patterns here +] +``` + +## Testing + +### Unit Tests + +Run `cargo test` to execute all unit tests. + +The unit tests include: + +#### Configuration Tests (`config.rs`) + +- **Config Parsing**: Tests parsing of TOML configuration files with various settings +- **Invalid File Handling**: Tests error handling for non-existent configuration files +- **Invalid TOML Handling**: Tests error handling for malformed TOML syntax +- **Whitelist Creation**: Tests creation and validation of command whitelists +- **Blacklist Creation**: Tests creation and validation of command blacklists +- **Settings Creation**: Tests creation of server settings with port, host, and environment configurations + +#### Validator Tests (`validator.rs`) + +- **Validator Creation**: Tests creation of command validators with blacklist and whitelist rules (both commands and regex) +- **Whitelisted Command Validation**: Tests allowing commands that exactly match whitelist entries +- **Whitelisted Regex Validation**: Tests allowing commands that match whitelist regex patterns +- **Blacklisted Command Blocking**: Tests blocking of commands that exactly match blacklist entries (complete command string) +- **Blacklisted Regex Blocking**: Tests blocking of commands matching blacklist regex patterns +- **Priority Testing**: Tests that blacklist has higher priority than whitelist +- **Exact vs Partial Match Testing**: Tests that blacklist only blocks exact command matches, not partial matches +- **Default Deny Behavior**: Tests that commands not in whitelist are denied by default +- **Empty Command Handling**: Tests that empty commands are denied by default +- **Case Sensitivity Testing**: Tests case-sensitive command matching behavior +- **Invalid Regex Handling**: Tests error handling for invalid regex patterns +- **Complex Command Testing**: Tests validation of multi-argument commands +- **Nested Shell Command Testing**: Tests validation of commands within shell invocations + +#### Bash Server Tests (`bash_server.rs`) + +- **Response Success/Failure**: Tests command execution response handling for both successful and failed commands +- **Command Stringification**: Tests conversion of Command objects to readable string representations +- **Command Execution with Timeout**: Tests command execution with timeout protection +- **Server Creation**: Tests BashServer instantiation with and without configuration +- **Request/Response Serialization**: Tests JSON serialization and deserialization of command requests and responses +- **Environment Variable Handling**: Tests setting and using environment variables in commands +- **Working Directory Handling**: Tests command execution in specific working directories +- **Debug and Clone Traits**: Tests that BashServer properly implements Debug and Clone traits +- **Timeout Behavior**: Tests that commands properly timeout when exceeding time limits +- **Unix script and Python Execution**: Tests tool `execute_script` and `execute_python` + +### Manual Testing with Inspector + +Use the official MCP debugging tool modelcontextprotocol/inspector. For usage instructions, refer to the [inspector documentation](https://github.com/modelcontextprotocol/inspector). + +If you deploy the MCP Server locally using the default method, run the inspector debugging tool after the server starts. + +```shell +npx @modelcontextprotocol/inspector +``` + +Then set the connection method to `Streamable HTTP` and set the URL to `http://127.0.0.1:4000/mcp`. + +### Using OAuth in Inspector (Optional) + +If your running mode is `development`, you don't need to go through `Open Auth Settings` and can directly click Connect to connect to the MCP Server. If your running mode is `production`, you need to complete the `Open Auth Settings` verification. + +The method for using OAuth verification and connection is as follows: + +1. Click `Open Auth Settings` +2. Click `Quick OAuth Flow` +3. Click `Approve` on the pop-up webpage +4. Return to the MCP inspector, click on the Access Tokens under `Authentication Complete` in `OAuth Flow Progress`. Copy the `access_token` from there +5. Click `Authentication`, paste the previously copied token into the `Bearer Token` field, then click Connect + +After connecting, you can test the various tools provided by the MCP Server in the inspector. + +## Tools + +The MCP Server currently provides the following tools: + +### Command Execution Tools + +1. **all_execute_via_default_shell** + - Description: Execute commands using the default shell on all types of operating systems + - Parameters: + - `command`: The bash command or script to execute + - `working_dir`: Working directory for the command (optional) + - `env_vars`: Environment variables (optional) + - `timeout_seconds`: Timeout in seconds (default: 30) + - Returns: Execution result with stdout, stderr, exit code, and success status + +2. **execute_python** + - Description: Execute a Python script + - Parameters: Same as above, but command should be Python code + - Returns: Python script execution result + +3. **execute_script** + - Description: Execute a Unix script by writing it to a temporary file + - Parameters: Same as above, command should be shell script content + - Returns: Script execution result + +### System Information Tools + +1. **unix_get_system_info_via_default_shell** + - Description: Get comprehensive system information including OS, kernel, memory, and disk usage + - Parameters: None + - Returns: Detailed system information including hostname, OS, kernel, architecture, uptime, memory usage, and disk usage + +2. **unix_preset_get_system_info_via_default_shell** + - Description: Get formatted system information in a structured format + - Parameters: None + - Returns: Version, hostname, and uptime in a single line format + +3. **unix_get_available_shell** + - Description: Get the available shells on Unix-like operating systems + - Parameters: None + - Returns: List of available shells from /etc/shells + +### Performance Monitoring Tools + +1. **unix_preset_get_nic_info_via_default_shell** + - Description: Get network interface statistics including receive and transmit bytes + - Parameters: None + - Returns: Interface names and traffic data in tabular format + +2. **unix_preset_get_cpu_info_via_default_shell** + - Description: Get detailed CPU information including model, core count, load averages, and performance metrics + - Parameters: None + - Returns: CPU model, core count, load averages, idle percentage, and other performance metrics + +3. **unix_preset_get_disk_free_info_via_default_shell** + - Description: Get disk usage information for all mounted filesystems + - Parameters: None + - Returns: Filesystem usage data with columns for filesystem, used, available, usage percentage, and mount point + +4. **unix_preset_get_top10_cpu_processes_via_default_shell** + - Description: Get top 10 processes consuming the most CPU + - Parameters: None + - Returns: Process information sorted by CPU usage in descending order + +5. **unix_preset_get_top10_mem_processes_via_default_shell** + - Description: Get top 10 processes consuming the most memory + - Parameters: None + - Returns: Process information sorted by memory usage in descending order + +### Security Features + +All command execution tools support comprehensive security validation: + +- **Default Deny Policy**: All commands are denied by default unless explicitly allowed +- **Blacklist Priority**: Blacklist rules have higher priority than whitelist rules +- **Dual Validation Modes**: Both exact string matching and regex pattern matching for blacklist and whitelist +- **Command Validation Logic**: + 1. Empty commands are denied by default + 2. If the **complete command string** exactly matches any blacklist command entry, deny immediately + 3. If the **complete command string** matches any blacklist regex pattern, deny immediately + 4. If command doesn't match blacklist and the **complete command string** exactly matches any whitelist command entry, allow + 5. If command doesn't match blacklist and the **complete command string** matches any whitelist regex pattern, allow + 6. Otherwise, deny by default +- **Important**: Blacklist validation checks the **entire command string**, not individual arguments. For example, if `"rm"` is blacklisted, only the exact command `"rm"` is blocked, not commands like `"rm -rf /tmp/test"` (which would be evaluated separately against the whitelist) +- **Timeout Protection**: Commands have configurable timeout limits to prevent hanging +- **Environment Isolation**: Optional working directory and environment variable settings +- **Logging**: All command executions and validation results are logged for audit purposes +- **Nested Shell Protection**: Validates commands within shell invocations (e.g., `bash -c "command"`) + +### Tool Categories + +- **General Execution**: Tools 1-3 for executing arbitrary commands, Python scripts, and shell scripts +- **System Monitoring**: Tools 1-3 for gathering basic system information +- **Performance Analysis**: Tools 1-5 for detailed performance monitoring and process analysis diff --git a/mcp-servers/mcp-bash-server/config.toml b/mcp-servers/mcp-bash-server/config.toml new file mode 100644 index 00000000000..abdbb2fedd7 --- /dev/null +++ b/mcp-servers/mcp-bash-server/config.toml @@ -0,0 +1,68 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# This is the configuration file for mcp-bash-server + +[settings] +# Port for MCP Server deployment +port = 4000 +# IP for MCP Server deployment +host = "127.0.0.1" +# Usage environment for MCP Server, can be development or production environment. +# Set env to "development" or "production", production uses oauth2.0. +env = "production" + +[whitelist] +# Whitelist of allowed commands (exact match), a string list. +# Only commands where the complete command string exactly matches an item in this list will be allowed +commands = [ + "echo hello", + "ls -la", + "pwd", + # Add your allowed commands here +] +# Whitelist of allowed command regex patterns, a regex expression string list +# Commands where the complete command string matches any of these regex patterns will be allowed +regex = [ + '^echo [a-zA-Z0-9 ]+$', + '^ls [a-zA-Z0-9 /-]*$', + # Add your whitelist regex patterns here +] + +[blacklist] +# Blacklist of forbidden commands (exact match), a string list +# Blacklist has higher priority than whitelist. If the complete command string exactly matches an item in this list, the entire command will be blocked, even if it would be allowed by the whitelist +# Note: Only exact matches are blocked. For example, if "rm" is blacklisted, only the exact command "rm" is blocked, not commands like "rm -rf /tmp/test" +commands = [ + # Dangerous file operations + "rm -rf /", + "shutdown", + # Add your forbidden commands here +] +# Blacklist of forbidden command regex patterns, a regex expression string list +# Blacklist has higher priority than whitelist. If the complete command string matches any of these regex patterns, it will be blocked, even if it would be allowed by the whitelist +regex = [ + # Block any command with dangerous operators + '.*[|&;`$()><].*', + # Block commands that try to write to system directories + '.*/etc/.*', + '.*/root/.*', + # Block commands with sudo or su + '^sudo .*', + '^su .*', + # Add your blacklist regex patterns here +] \ No newline at end of file diff --git a/mcp-servers/mcp-bash-server/src/common/bash_server.rs b/mcp-servers/mcp-bash-server/src/common/bash_server.rs new file mode 100644 index 00000000000..5aa0d553335 --- /dev/null +++ b/mcp-servers/mcp-bash-server/src/common/bash_server.rs @@ -0,0 +1,1224 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +//! Bash Server Implementation for MCP (Model Context Protocol) +//! +//! This module provides a bash command execution server that can run shell commands +//! safely with validation and timeout controls. It supports multiple operating systems +//! and provides various execution methods for different use cases. + +#![allow(unused_imports, unused_variables, dead_code)] +use rmcp::model::Content; +use rmcp::{ + RoleServer, ServerHandler, + handler::server::{ + router::tool::ToolRouter, + tool::{IntoCallToolResult, Parameters}, + }, + model::*, + schemars, + serde_json::Value, + service::RequestContext, + tool, +}; +use rmcp::{serde_json, tool_handler, tool_router}; +use serde::{Deserialize, Serialize}; +use std::ffi::OsStr; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +use std::process::Output; +use std::{ + borrow::Cow, + env, + fs::{self, File}, + io::Write, + process::Command, +}; +use tracing::error; +use tracing::info; +use uuid::Uuid; + +use crate::common::config::Config; +use crate::common::validator::Validator; + +/// Request structure for executing bash commands +/// Contains all necessary parameters for command execution including +/// working directory, environment variables, and timeout settings +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct DefaultExecuteRequest { + #[schemars(description = "The bash command or script to execute")] + pub command: String, + #[schemars(description = "Working directory for the command (optional)")] + pub working_dir: Option, + #[schemars(description = "Environment variables (optional)")] + pub env_vars: Option>, + #[schemars(description = "Timeout in seconds (default: 30)")] + pub timeout_seconds: Option, +} + +/// Response structure for command execution results +/// Contains stdout, stderr, exit code, success status and any parsed data +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DefaultExecuteResponse { + /// Standard output from the executed command + pub stdout: String, + /// Standard error output from the executed command + pub stderr: String, + /// Exit code returned by the command (0 for success, non-zero for failure) + pub exit_code: i32, + /// Whether the command executed successfully (exit code 0) + pub success: bool, + /// Additional parsed data from command output (JSON format) + pub parsed_data: serde_json::Value, +} + +impl IntoCallToolResult for DefaultExecuteResponse { + fn into_call_tool_result(self) -> Result { + let content = if self.success { + format!( + "Command executed successfully (exit code: {})\n\nSTDOUT:\n{}\n\nSTDERR:\n{}", + self.exit_code, self.stdout, self.stderr + ) + } else { + format!( + "Command failed (exit code: {})\n\nSTDOUT:\n{}\n\nSTDERR:\n{}", + self.exit_code, self.stdout, self.stderr + ) + }; + + Ok(CallToolResult { + content: Some(vec![Content::text(content)]), + structured_content: None, + is_error: Some(!self.success), + }) + } +} + +/// Main bash server implementation that handles command execution +/// with optional validation and timeout controls +#[derive(Debug, Clone)] +pub struct BashServer { + validator: Option, + tool_router: ToolRouter, +} + +/// Trait for command execution utilities +/// Provides common functionality for command formatting and execution +pub trait CommandRunner { + /// Convert a Command instance to a readable string representation + /// Handles proper quoting of arguments containing spaces + fn stringify_command(cmd: &Command) -> String { + let program = cmd.get_program().to_string_lossy(); + let args = cmd + .get_args() + .map(|arg| { + let s = arg.to_string_lossy(); + if s.contains(' ') || s.contains('"') { + format!("{s:?}") + } else { + s.to_string() + } + }) + .collect::>() + .join(" "); + + format!("{program} {args}") + } + + /// Execute a command with timeout protection + /// Returns command output or timeout error + async fn execute_command_with_timeout( + timeout: std::time::Duration, + mut cmd: Command, + ) -> Result { + let cmd_str = Self::stringify_command(&cmd); + // Execute command with timeout + let output = tokio::time::timeout(timeout, async { + tokio::task::spawn_blocking(move || cmd.output()).await + }) + .await + .map_err(|_| ErrorData { + code: ErrorCode::INTERNAL_ERROR, + message: Cow::Owned("Command execution timed out".to_string()), + data: None, + })? + .map_err(|e| ErrorData { + code: ErrorCode::INTERNAL_ERROR, + message: Cow::Owned(format!("Failed to spawn command: {e}")), + data: None, + })? + .map_err(|e| ErrorData { + code: ErrorCode::INTERNAL_ERROR, + message: Cow::Owned(format!("Command execution failed: {e}")), + data: None, + })?; + + // log execution of command + info!("Execute command: {cmd_str}"); + Ok(output) + } +} + +impl CommandRunner for BashServer {} + +impl BashServer { + /// Create a new BashServer instance + /// Attempts to load configuration from config.toml, creates validator if successful + pub fn new() -> Self { + let tool_router = Self::tool_router(); + if let Ok(config) = Config::read_config("config.toml") + .inspect_err(|e| eprintln!("read config.toml fail, error: {e}")) + { + let blacklist = config.blacklist; + let whitelist = config.whitelist; + BashServer { + validator: Some(Validator::new(blacklist, whitelist)), + tool_router, + } + } else { + Self { + validator: None, + tool_router, + } + } + } + + /// Internal method for executing commands via default shell + /// Supports validation toggle and handles cross-platform shell differences + async fn _all_execute_via_default_shell( + &self, + need_validate: bool, + request: DefaultExecuteRequest, + ) -> Result { + let timeout_duration = + std::time::Duration::from_secs(request.timeout_seconds.unwrap_or(30)); + + // Parse command using shlex to split into command and arguments + let command_parts = shlex::split(&request.command) + .ok_or_else(|| ErrorData::invalid_params("Failed to parse command", None))?; + + if command_parts.is_empty() { + return Err(ErrorData::invalid_params("Empty command", None)); + } + + let program = &command_parts[0]; + let args = &command_parts[1..]; + + info!("Executing command: {} {:?}", program, args); + + let mut cmd = Command::new(program); + cmd.args(args); + + // Set working directory if provided + if let Some(working_dir) = &request.working_dir { + cmd.current_dir(working_dir); + } + + // Set environment variables if provided + if let Some(env_vars) = &request.env_vars { + for (key, value) in env_vars { + cmd.env(key, value); + } + } + + // Validate the commands + if let Some(validator) = &self.validator + && need_validate + { + validator.is_unsafe_command(&request.command)?; + } + + let output: Output = Self::execute_command_with_timeout(timeout_duration, cmd).await?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let exit_code = output.status.code().unwrap_or(-1); + let success = output.status.success(); + + let response = DefaultExecuteResponse { + stdout, + stderr, + exit_code, + success, + parsed_data: Value::Null, + }; + Ok(CallToolResult::success(vec![Content::json(response)?])) + } + + /// Execute python script on Windows systems + #[cfg(windows)] + async fn windows_execute_python( + &self, + request: DefaultExecuteRequest, + ) -> Result { + let timeout_duration = + std::time::Duration::from_secs(request.timeout_seconds.unwrap_or(30)); + + // Try to find Python executable on Windows + // First try 'python', then 'python3', then 'py' + let python_commands = ["python", "python3", "py"]; + let mut cmd = None; + + for python_cmd in &python_commands { + if let Ok(output) = std::process::Command::new(python_cmd) + .arg("--version") + .output() + { + if output.status.success() { + cmd = Some(Command::new(python_cmd)); + break; + } + } + } + + let mut cmd = cmd.ok_or_else(|| ErrorData { + code: ErrorCode::INTERNAL_ERROR, + message: Cow::Owned( + "Python execution failed: No Python interpreter found on system. Please install Python and ensure it's in your PATH.".to_string(), + ), + data: None, + })?; + + cmd.arg("-c").arg(&request.command); + + // Set working directory if provided + if let Some(working_dir) = &request.working_dir { + cmd.current_dir(working_dir); + } + + // Set environment variables if provided + if let Some(env_vars) = &request.env_vars { + for (key, value) in env_vars { + cmd.env(key, value); + } + } + + let output: Output = Self::execute_command_with_timeout(timeout_duration, cmd).await?; + + // log the execution of python + info!("Execute python script: {}", &request.command); + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let exit_code = output.status.code().unwrap_or(-1); + let success = output.status.success(); + + let response = DefaultExecuteResponse { + stdout, + stderr, + exit_code, + success, + parsed_data: Value::Null, + }; + Ok(CallToolResult::success(vec![Content::json(response)?])) + } + + #[cfg(windows)] + async fn windows_execute_script( + &self, + request: DefaultExecuteRequest, + ) -> Result { + if let Some(validator) = &self.validator { + for line in request.command.lines() { + if line.is_empty() || line.starts_with('#') { + continue; + } + validator.is_unsafe_command(line)?; + } + } + + let timeout_duration = + std::time::Duration::from_secs(request.timeout_seconds.unwrap_or(30)); + + // Write the script to a temporary file + let tmp_dir = env::temp_dir(); + let tmp_name = Uuid::new_v4().to_string(); + let script_path = tmp_dir.join(format!("{tmp_name}.ps1")); + { + let mut file = File::create(&script_path).map_err(|_| { + ErrorData::new( + ErrorCode::INTERNAL_ERROR, + "Can not create temporary script file", + None, + ) + })?; + file.write_all(request.command.as_bytes()).map_err(|_| { + ErrorData::new( + ErrorCode::INTERNAL_ERROR, + "Write to script file error", + None, + ) + })?; + } + + let mut cmd = Command::new("powershell"); + cmd.arg("-ExecutionPolicy") + .arg("Bypass") + .arg("-File") + .arg(script_path); + // Execute the script + let output: Output = Self::execute_command_with_timeout(timeout_duration, cmd).await?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let exit_code = output.status.code().unwrap_or(-1); + let success = output.status.success(); + + let response = DefaultExecuteResponse { + stdout, + stderr, + exit_code, + success, + parsed_data: Value::Null, + }; + Ok(CallToolResult::success(vec![Content::json(response)?])) + } + + /// Build CPU information gathering command + /// Combines multiple shell commands to collect comprehensive CPU data + fn build_cpu_info_command() -> String { + let cpu_commands = [ + r#"LANG=C lscpu | awk -F: '$1=="Model name" {print $2}'"#, // CPU model + r#"awk '/processor/{core++} END{print core}' /proc/cpuinfo"#, // Core count + r#"uptime | sed 's/,/ /g' | awk '{for(i=NF-2;i<=NF;i++)print $i }' | xargs"#, // Load averages + r#"vmstat 1 1 | awk 'NR==3{print $11}'"#, // Interrupts + r#"vmstat 1 1 | awk 'NR==3{print $12}'"#, // Context switches + r#"vmstat 1 2 | awk 'NR==4{print $15}'"#, // CPU idle percentage + ]; + + format!(r#"bash -c "{}""#, cpu_commands.join(";")) + } + + /// Build system information gathering command + /// Collects kernel version, hostname, and uptime in structured format + fn build_system_info_command() -> String { + let system_commands = [ + "uname -r", // Kernel version + "hostname", // Hostname + r#"uptime | awk -F "," '{print $1}' | sed "s/ //g""#, // Uptime (first part) + ]; + + let combined_command = format!( + "({}) | sed \":a;N;s/\\n/^/g;ta\" | awk -F \"^\" 'BEGIN{{print \"version hostname uptime\"}} {{print $1, $2, $3}}'", + system_commands.join(" ; ") + ); + + format!(r#"bash -c '{combined_command}'"#) + } + + /// Build top CPU processes gathering command + /// Gets top 10 processes sorted by CPU usage + fn build_top_cpu_processes_command() -> String { + let process_commands = [ + "ps aux", // Get all processes + "sort -k3nr", // Sort by CPU usage (descending) + r#"awk 'BEGIN{ print "pid cpu_usage mem_usage command" } {printf "%s %s %s ", $2, $3, $4; for (i=11; i<=NF; i++) { printf "%s", $i; if (i < NF) printf " "; } print ""}'"#, // Format output + "head -n 11", // Get top 10 + header + ]; + + format!(r#"bash -c "{}""#, process_commands.join(" | ")) + } + + /// Build top memory processes gathering command + /// Gets top 10 processes sorted by memory usage + fn build_top_mem_processes_command() -> String { + let process_commands = [ + "ps aux", // Get all processes + "sort -k4nr", // Sort by memory usage (descending) + r#"awk 'BEGIN{ print "pid cpu_usage mem_usage command" } {printf "%s %s %s ", $2, $3, $4; for (i=11; i<=NF; i++) { printf "%s", $i; if (i < NF) printf " "; } print ""}'"#, // Format output + "head -n 11", // Get top 10 + header + ]; + + format!(r#"bash -c "{}""#, process_commands.join(" | ")) + } + + /// Execute a python script on Unix-like systems + #[cfg(unix)] + async fn unix_execute_python( + &self, + request: DefaultExecuteRequest, + ) -> Result { + let timeout_duration = + std::time::Duration::from_secs(request.timeout_seconds.unwrap_or(30)); + + // Check if /usr/bin/env exists before proceeding + let env_exists = std::path::Path::new("/usr/bin/env").exists(); + if !env_exists { + return Err(ErrorData { + code: ErrorCode::INTERNAL_ERROR, + message: Cow::Owned( + "Python execution failed: /usr/bin/env not found on system".to_string(), + ), + data: None, + }); + } + + let mut cmd = Command::new("/usr/bin/env"); + cmd.arg("python3").arg("-c").arg(&request.command); + + // Set working directory if provided + if let Some(working_dir) = &request.working_dir { + cmd.current_dir(working_dir); + } + + // Set environment variables if provided + if let Some(env_vars) = &request.env_vars { + for (key, value) in env_vars { + cmd.env(key, value); + } + } + + let output: Output = Self::execute_command_with_timeout(timeout_duration, cmd).await?; + + // log the execution of python + info!("Execute python script: {}", &request.command); + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let exit_code = output.status.code().unwrap_or(-1); + let success = output.status.success(); + + let response = DefaultExecuteResponse { + stdout, + stderr, + exit_code, + success, + parsed_data: Value::Null, + }; + Ok(CallToolResult::success(vec![Content::json(response)?])) + } + + /// Execute a Unix script by writing it to a temporary file + /// Creates a temporary shell script file with proper permissions and executes it + #[cfg(unix)] + async fn unix_execute_script( + &self, + request: DefaultExecuteRequest, + ) -> Result { + if let Some(validator) = &self.validator { + for line in request.command.lines() { + if line.is_empty() || line.starts_with('#') { + continue; + } + validator.is_unsafe_command(line)?; + } + } + + let timeout_duration = + std::time::Duration::from_secs(request.timeout_seconds.unwrap_or(30)); + + // Write the string to a temporary file + let tmp_dir = env::temp_dir(); + // Generate a random script name + let tmp_name = Uuid::new_v4().to_string(); + let script_path = tmp_dir.join(format!("{tmp_name}.sh")); + { + let mut file = File::create(&script_path).map_err(|_| { + ErrorData::new( + ErrorCode::INTERNAL_ERROR, + "Can not create temporary script file", + None, + ) + })?; + file.write_all(request.command.as_bytes()).map_err(|_| { + ErrorData::new( + ErrorCode::INTERNAL_ERROR, + "Write to script file error", + None, + ) + })?; + // Set execution mode + let metadata = file.metadata().map_err(|_| { + ErrorData::new(ErrorCode::INTERNAL_ERROR, "Get file metadata error", None) + })?; + let mut perms = metadata.permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).map_err(|_| { + ErrorData::new( + ErrorCode::INTERNAL_ERROR, + "Failed to set script file permissions", + None, + ) + })?; + } + + let mut cmd = Command::new(&script_path); + + // Set working directory if provided + if let Some(working_dir) = &request.working_dir { + cmd.current_dir(working_dir); + } + + // Set environment variables if provided + if let Some(env_vars) = &request.env_vars { + for (key, value) in env_vars { + cmd.env(key, value); + } + } + + let output: Output = Self::execute_command_with_timeout(timeout_duration, cmd).await?; + info!("Execute script:\n{}", request.command); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let exit_code = output.status.code().unwrap_or(-1); + let success = output.status.success(); + + let response = DefaultExecuteResponse { + stdout, + stderr, + exit_code, + success, + parsed_data: Value::Null, + }; + Ok(CallToolResult::success(vec![Content::json(response)?])) + } +} + +#[tool_router] +impl BashServer { + #[tool(description = "Execute commands using default shell in all kinds of os")] + async fn all_execute_via_default_shell( + &self, + Parameters(request): Parameters, + ) -> Result { + self._all_execute_via_default_shell(true, request).await + } + + /// Execute a python script + #[tool(description = "Execute a python script")] + async fn execute_python( + &self, + Parameters(request): Parameters, + ) -> Result { + #[cfg(unix)] + let res = self.unix_execute_python(request).await; + #[cfg(windows)] + let res = self.windows_execute_python(request).await; + res + } + + /// Execute a Unix script by writing it to a temporary file + /// Creates a temporary shell script file with proper permissions and executes it + #[tool(description = "Execute a script")] + async fn execute_script( + &self, + Parameters(request): Parameters, + ) -> Result { + #[cfg(unix)] + let res = self.unix_execute_script(request).await; + #[cfg(windows)] + let res = self.windows_execute_script(request).await; + res + } + + /// Get formatted system information in a structured format + /// Returns version, hostname, and uptime in a single line format + #[tool(description = "Get system information using bash commands")] + async fn unix_preset_get_system_info_via_default_shell( + &self, + ) -> Result { + let command = Self::build_system_info_command(); + + self._all_execute_via_default_shell( + false, + DefaultExecuteRequest { + command: command.to_string(), + working_dir: None, + env_vars: None, + timeout_seconds: Some(10), + }, + ) + .await + } + + /// Get list of available shells from /etc/shells + /// Filters out comments and validates shell executable existence + #[tool(description = "Get the available shell in unix-like os")] + async fn unix_get_available_shell(&self) -> Result { + let mut available_shell = vec![]; + let shells_file = "/etc/shells"; + match fs::read_to_string(shells_file) { + Ok(content) => { + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + // Return the available shells + if fs::metadata(line).map(|m| m.is_file()).unwrap_or(false) { + available_shell.push(Content::text(line)); + } + } + info!("Read file /etc/shells"); + } + Err(e) => { + error!("Failed to read {shells_file}: {e}"); + return Err(ErrorData { + code: ErrorCode::INTERNAL_ERROR, + message: Cow::Owned(format!("Failed to read {shells_file}: {e}")), + data: None, + }); + } + } + let result = CallToolResult::success(available_shell); + Ok(result) + } + + /// Get network interface statistics including receive and transmit bytes + /// Parses /proc/net/dev to extract interface names and traffic data + #[tool(description = "Get the nic info through the default shell")] + async fn unix_preset_get_nic_info_via_default_shell( + &self, + ) -> Result { + let command = r#"bash -c "cat /proc/net/dev | tail -n +3 | awk 'BEGIN{ print \"interface_name receive_bytes transmit_bytes\"} {print $1,$2,$10}'""#; + let result = self + ._all_execute_via_default_shell( + false, + DefaultExecuteRequest { + command: command.to_string(), + working_dir: None, + env_vars: None, + timeout_seconds: Some(5), + }, + ) + .await?; + + Ok(result) + } + + /// Get detailed CPU information including model, core count, load averages, and performance metrics + /// Combines data from lscpu, /proc/cpuinfo, uptime, and vmstat commands + /// Parses the output to provide structured CPU performance data + #[tool(description = "Get the cpu info through the default shell")] + async fn unix_preset_get_cpu_info_via_default_shell( + &self, + ) -> Result { + let command = Self::build_cpu_info_command(); + let mut result = self + ._all_execute_via_default_shell( + false, + DefaultExecuteRequest { + command: command.to_string(), + working_dir: None, + env_vars: None, + timeout_seconds: Some(5), + }, + ) + .await?; + if result.content.is_none() { + return Err(ErrorData::internal_error("Result content is null", None)); + } + if result.content.as_ref().unwrap().is_empty() { + return Err(ErrorData::internal_error("Result content is empty", None)); + } + let raw_content = &mut result.content.as_mut().unwrap().get_mut(0).unwrap().raw; + if let RawContent::Text(RawTextContent { text }) = raw_content { + if let Ok(mut response) = serde_json::from_str::(&text.clone()) + { + // parse response.stdout + // output e.g.: + // Intel(R) Core(TM) i7-10875H CPU @ 2.30GHz + // 16 + // 1.05 0.74 0.72 + // 1261 + // 5 + // 92 + let lines: Vec<&str> = response + .stdout + .lines() + .map(|l| l.trim()) + .filter(|l| !l.is_empty()) + .collect(); + if lines.len() >= 6 { + let cpu_model = lines[0]; + let core_count = lines[1].parse::().unwrap_or(0); + let load: Vec = lines[2] + .split_whitespace() + .filter_map(|s| s.parse::().ok()) + .collect(); + let interrupt = lines[3].parse::().unwrap_or(0); + let context_switch = lines[4].parse::().unwrap_or(0); + let idle = lines[5].parse::().unwrap_or(0); + + let parsed = serde_json::json!({ + "cpu_model": cpu_model, + "core_count": core_count, + "load": load, + "interrupt": interrupt, + "context_switch": context_switch, + "idle": idle + }); + response.parsed_data = parsed; + *text = serde_json::to_string(&response).unwrap(); + } + } + } + + Ok(result) + } + + /// Get disk usage information for all mounted filesystems + /// Returns filesystem usage data in tabular format with columns for filesystem, used, available, usage percentage, and mount point + #[tool(description = "Get the disk free info through the default shell")] + async fn unix_preset_get_disk_free_info_via_default_shell( + &self, + ) -> Result { + let command = r#"bash -c "df -mP | tail -n +2 | awk 'BEGIN{ print \"filesystem used available usage mounted\"} {print $1,$3,$4,$5,$6}'""#; + let result = self + ._all_execute_via_default_shell( + false, + DefaultExecuteRequest { + command: command.to_string(), + working_dir: None, + env_vars: None, + timeout_seconds: Some(5), + }, + ) + .await?; + Ok(result) + } + + /// Get top 10 processes consuming the most CPU + /// Returns process information sorted by CPU usage in descending order + #[tool(description = "Get the top 10 cpu processes through the default shell")] + async fn unix_preset_get_top10_cpu_processes_via_default_shell( + &self, + ) -> Result { + let command = Self::build_top_cpu_processes_command(); + let result = self + ._all_execute_via_default_shell( + false, + DefaultExecuteRequest { + command: command.to_string(), + working_dir: None, + env_vars: None, + timeout_seconds: Some(5), + }, + ) + .await?; + Ok(result) + } + + /// Get top 10 processes consuming the most memory + /// Returns process information sorted by memory usage in descending order + #[tool(description = "Get the top 10 mem processes through the default shell")] + async fn unix_preset_get_top10_mem_processes_via_default_shell( + &self, + ) -> Result { + let command = Self::build_top_mem_processes_command(); + let result = self + ._all_execute_via_default_shell( + false, + DefaultExecuteRequest { + command: command.to_string(), + working_dir: None, + env_vars: None, + timeout_seconds: Some(5), + }, + ) + .await?; + Ok(result) + } +} + +#[tool_handler] +impl ServerHandler for BashServer { + /// Returns server information including protocol version, capabilities, and usage instructions + fn get_info(&self) -> ServerInfo { + ServerInfo { + protocol_version: ProtocolVersion::LATEST, + capabilities: ServerCapabilities::builder() + .enable_prompts() + .enable_tools() + .enable_resources() + .enable_logging() + .build(), + instructions: Some( + r#"A Model Context Protocol server that can execute shell commands and scripts in the machine server deployed at. + Use the `execute_via_default_shell` tool to run any shell command. + Use `execute_unix_script` to run any scripts in unix-like os. + Use `execute_python` to run any python scripts. + Use `get_system_info` to get basic system information."# + .to_string(), + ), + ..Default::default() + } + } + + /// Set the logging level for the server + /// Sends a logging message notification to the connected peer + async fn set_level( + &self, + SetLevelRequestParam { level }: SetLevelRequestParam, + context: RequestContext, + ) -> Result<(), ErrorData> { + let params = LoggingMessageNotificationParam { + level, + logger: Some("Server".to_string()), + data: Value::String(format!("logging level is {level:?}")), + }; + context + .peer + .notify_logging_message(params) + .await + .map_err(|e| ErrorData { + code: ErrorCode::INTERNAL_ERROR, + message: Cow::Owned(format!("error message: {e:?}")), + data: None, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::common::config::{Blacklist, Whitelist}; + use std::collections::HashMap; + + fn create_test_bash_server() -> BashServer { + let blacklist = Blacklist { + commands: vec!["rm".to_string(), "dd".to_string()], + regex: vec![".*[|&].*".to_string()], + }; + + let whitelist = Whitelist { + commands: vec!["echo hello".to_string()], + regex: vec!["echo.*".to_string()], + }; + + let validator = Validator::new(blacklist, whitelist); + let tool_router = BashServer::tool_router(); + + BashServer { + validator: Some(validator), + tool_router, + } + } + + fn create_test_request(command: &str) -> DefaultExecuteRequest { + DefaultExecuteRequest { + command: command.to_string(), + working_dir: None, + env_vars: None, + timeout_seconds: Some(5), + } + } + + #[tokio::test] + async fn test_execute_script() { + let server = create_test_bash_server(); + let safe_request = create_test_request( + r#"#!/bin/bash +echo hello +"#, + ); + let result = server.execute_script(Parameters(safe_request)).await; + assert!(result.is_ok()); + let call_result = result.unwrap(); + assert!(!call_result.content.unwrap().is_empty()); + let unsafe_request = create_test_request( + r#"#!/bin/bash +touch hello.txt +rm -rf hello.txt +"#, + ); + let result = server.execute_script(Parameters(unsafe_request)).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_execute_python() { + let server = create_test_bash_server(); + let safe_request = create_test_request( + r#"import os +import subprocess +print("hello world") +subprocess.run(["ls", "-l"], cwd="/root") +"#, + ); + let result = server.execute_python(Parameters(safe_request)).await; + assert!(result.is_ok()); + let content = result.unwrap().content.unwrap()[0].clone(); + if let RawContent::Text(RawTextContent { text }) = content.raw { + let response = serde_json::from_str::(&text); + assert!(response.is_ok()); + let response = response.unwrap(); + assert!(!response.success); + assert!(response.exit_code != 0); + } + } + + #[test] + fn test_default_execute_response_success() { + let response = DefaultExecuteResponse { + stdout: "Hello World".to_string(), + stderr: "".to_string(), + exit_code: 0, + success: true, + parsed_data: Value::Null, + }; + + let result = response.into_call_tool_result(); + assert!(result.is_ok()); + + let call_result = result.unwrap(); + assert_eq!(call_result.is_error, Some(false)); + assert!(!call_result.content.unwrap().is_empty()); + } + + #[test] + fn test_default_execute_response_failure() { + let response = DefaultExecuteResponse { + stdout: "".to_string(), + stderr: "Command not found".to_string(), + exit_code: 127, + success: false, + parsed_data: Value::Null, + }; + + let result = response.into_call_tool_result(); + assert!(result.is_ok()); + + let call_result = result.unwrap(); + assert_eq!(call_result.is_error, Some(true)); + assert!(!call_result.content.unwrap().is_empty()); + } + + #[test] + fn test_command_runner_stringify_simple_command() { + let mut cmd = Command::new("echo"); + cmd.arg("hello"); + + let result = BashServer::stringify_command(&cmd); + assert_eq!(result, "echo hello"); + } + + #[test] + fn test_command_runner_stringify_command_with_spaces() { + let mut cmd = Command::new("echo"); + cmd.arg("hello world"); + + let result = BashServer::stringify_command(&cmd); + assert_eq!(result, "echo \"hello world\""); + } + + #[test] + fn test_command_runner_stringify_complex_command() { + let mut cmd = Command::new("find"); + cmd.arg("/tmp").arg("-name").arg("*.txt"); + + let result = BashServer::stringify_command(&cmd); + assert_eq!(result, "find /tmp -name *.txt"); + } + + #[tokio::test] + async fn test_execute_command_with_timeout_success() { + let cmd = if cfg!(target_os = "windows") { + let mut cmd = Command::new("cmd"); + cmd.arg("/c").arg("echo test"); + cmd + } else { + let mut cmd = Command::new("echo"); + cmd.arg("test"); + cmd + }; + + let result = + BashServer::execute_command_with_timeout(std::time::Duration::from_secs(5), cmd).await; + + assert!(result.is_ok()); + let output = result.unwrap(); + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("test")); + } + + #[tokio::test] + async fn test_execute_command_with_timeout_failure() { + let cmd = Command::new("nonexistent_command_12345"); + + let result = + BashServer::execute_command_with_timeout(std::time::Duration::from_secs(5), cmd).await; + + assert!(result.is_err()); + } + + #[test] + fn test_bash_server_creation_with_validator() { + let server = create_test_bash_server(); + assert!(server.validator.is_some()); + } + + #[test] + fn test_bash_server_creation_without_config() { + // This will create a server without validator since config.toml doesn't exist in test + let _server = BashServer::new(); + // In test environment, config.toml might not exist, so validator could be None + // This is expected behavior + } + + #[tokio::test] + async fn test_all_execute_via_default_shell_simple_command() { + let server = create_test_bash_server(); + let request = create_test_request("echo 'Hello Test'"); + + let result = server + .all_execute_via_default_shell(Parameters(request)) + .await; + assert!(result.is_ok()); + } + + #[test] + fn test_default_execute_request_creation() { + let mut env_vars = HashMap::new(); + env_vars.insert("TEST_VAR".to_string(), "test_value".to_string()); + + let request = DefaultExecuteRequest { + command: "echo $TEST_VAR".to_string(), + working_dir: Some("/tmp".to_string()), + env_vars: Some(env_vars), + timeout_seconds: Some(10), + }; + + assert_eq!(request.command, "echo $TEST_VAR"); + assert_eq!(request.working_dir, Some("/tmp".to_string())); + assert_eq!(request.timeout_seconds, Some(10)); + assert!(request.env_vars.is_some()); + assert_eq!( + request.env_vars.unwrap().get("TEST_VAR"), + Some(&"test_value".to_string()) + ); + } + + #[test] + fn test_default_execute_request_minimal() { + let request = DefaultExecuteRequest { + command: "pwd".to_string(), + working_dir: None, + env_vars: None, + timeout_seconds: None, + }; + + assert_eq!(request.command, "pwd"); + assert!(request.working_dir.is_none()); + assert!(request.env_vars.is_none()); + assert!(request.timeout_seconds.is_none()); + } + + #[test] + fn test_default_execute_response_with_parsed_data() { + let parsed_data = serde_json::json!({ + "status": "success", + "data": ["item1", "item2"] + }); + + let response = DefaultExecuteResponse { + stdout: "Command output".to_string(), + stderr: "".to_string(), + exit_code: 0, + success: true, + parsed_data, + }; + + assert_eq!(response.exit_code, 0); + assert!(response.success); + assert_eq!(response.parsed_data["status"], "success"); + assert!(response.parsed_data["data"].is_array()); + } + + #[tokio::test] + async fn test_execute_command_timeout() { + // Test with a very short timeout to ensure timeout behavior + let cmd = if cfg!(target_os = "windows") { + let mut cmd = Command::new("powershell"); + cmd.arg("Start-Sleep -Seconds 2"); // sleep 2s + cmd + } else { + let mut cmd = Command::new("sleep"); + cmd.arg("2"); // sleep 2s + cmd + }; + + let result = BashServer::execute_command_with_timeout( + std::time::Duration::from_millis(100), // Very short timeout + cmd, + ) + .await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!(error.message.contains("timed out")); + } + + #[test] + fn test_bash_server_debug_trait() { + let server = create_test_bash_server(); + let debug_str = format!("{server:?}"); + assert!(debug_str.contains("BashServer")); + } + + #[test] + fn test_bash_server_clone_trait() { + let server = create_test_bash_server(); + let cloned_server = server.clone(); + + // Both should have validators + assert!(server.validator.is_some()); + assert!(cloned_server.validator.is_some()); + } + + #[test] + fn test_default_execute_response_serialization() { + let response = DefaultExecuteResponse { + stdout: "output".to_string(), + stderr: "error".to_string(), + exit_code: 1, + success: false, + parsed_data: serde_json::json!({"key": "value"}), + }; + + let json_result = serde_json::to_string(&response); + assert!(json_result.is_ok()); + + let json_str = json_result.unwrap(); + assert!(json_str.contains("output")); + assert!(json_str.contains("error")); + assert!(json_str.contains("\"exit_code\":1")); + assert!(json_str.contains("\"success\":false")); + } + + #[test] + fn test_default_execute_response_deserialization() { + let json_str = r#"{ + "stdout": "test output", + "stderr": "test error", + "exit_code": 0, + "success": true, + "parsed_data": null + }"#; + + let result: Result = serde_json::from_str(json_str); + assert!(result.is_ok()); + + let response = result.unwrap(); + assert_eq!(response.stdout, "test output"); + assert_eq!(response.stderr, "test error"); + assert_eq!(response.exit_code, 0); + assert!(response.success); + assert_eq!(response.parsed_data, Value::Null); + } +} diff --git a/mcp-servers/mcp-bash-server/src/common/config.rs b/mcp-servers/mcp-bash-server/src/common/config.rs new file mode 100644 index 00000000000..2a9b4f7d2c1 --- /dev/null +++ b/mcp-servers/mcp-bash-server/src/common/config.rs @@ -0,0 +1,190 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +//! Configuration management for the MCP Bash Server +//! +//! This module handles reading and parsing configuration from TOML files, +//! including server settings and security blacklists for command validation. + +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::fs; + +#[derive(Debug, Deserialize, Serialize)] +pub struct Config { + pub settings: Settings, + pub blacklist: Blacklist, + pub whitelist: Whitelist, +} + +/// Security whitelist configuration for command validation +/// Contains lists of allowed commands and operations +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Whitelist { + /// List of command names that are allowed to be executed + pub commands: Vec, + /// List of regular expressions for commands that are allowed + /// These patterns are matched against the full command line + pub regex: Vec, +} + +/// Security blacklist configuration for command validation +/// Contains lists of forbidden commands and regex patterns +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Blacklist { + /// List of command names that are not allowed to be executed + pub commands: Vec, + /// List of regular expressions for commands that are not allowed + /// These patterns are matched against the full command line + pub regex: Vec, +} + +/// Server runtime settings including network configuration +#[derive(Debug, Deserialize, Serialize)] +pub struct Settings { + pub port: u16, + pub host: String, + pub env: Option, // "development" or "production" +} + +impl Config { + /// Read and parse configuration from a TOML file + /// Returns parsed Config structure or error if file cannot be read/parsed + pub fn read_config(file: &str) -> Result { + let toml_str = fs::read_to_string(file)?; + let config: Config = toml::from_str(&toml_str)?; + Ok(config) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + #[test] + fn test_config_parsing() { + let config_content = r#" +[settings] +port = 4000 +host = "127.0.0.1" +env = "development" + +[whitelist] +commands = ["echo hello", "ls -la"] +regex = ["echo.*", "ls.*"] + +[blacklist] +commands = ["rm", "dd"] +regex = [".*[|&].*", "^sudo .*"] +"#; + + let mut temp_file = NamedTempFile::new().unwrap(); + write!(temp_file, "{config_content}").unwrap(); + let temp_path = temp_file.path().to_str().unwrap(); + + let config = Config::read_config(temp_path).unwrap(); + + assert_eq!(config.settings.port, 4000); + assert_eq!(config.settings.host, "127.0.0.1"); + assert_eq!(config.settings.env, Some("development".to_string())); + + assert_eq!(config.whitelist.commands.len(), 2); + assert!( + config + .whitelist + .commands + .contains(&"echo hello".to_string()) + ); + assert!(config.whitelist.commands.contains(&"ls -la".to_string())); + + assert_eq!(config.whitelist.regex.len(), 2); + assert!(config.whitelist.regex.contains(&"echo.*".to_string())); + assert!(config.whitelist.regex.contains(&"ls.*".to_string())); + + assert_eq!(config.blacklist.commands.len(), 2); + assert!(config.blacklist.commands.contains(&"rm".to_string())); + assert!(config.blacklist.commands.contains(&"dd".to_string())); + + assert_eq!(config.blacklist.regex.len(), 2); + assert!(config.blacklist.regex.contains(&".*[|&].*".to_string())); + assert!(config.blacklist.regex.contains(&"^sudo .*".to_string())); + } + + #[test] + fn test_config_invalid_file() { + let result = Config::read_config("non_existent_file.toml"); + assert!(result.is_err()); + } + + #[test] + fn test_config_invalid_toml() { + let invalid_config = r#" +[settings +port = 4000 +"#; + + let mut temp_file = NamedTempFile::new().unwrap(); + write!(temp_file, "{invalid_config}").unwrap(); + let temp_path = temp_file.path().to_str().unwrap(); + + let result = Config::read_config(temp_path); + assert!(result.is_err()); + } + + #[test] + fn test_whitelist_creation() { + let commands = vec!["echo".to_string(), "ls".to_string()]; + let regex = vec!["test.*".to_string()]; + + let whitelist = Whitelist { + commands: commands.clone(), + regex: regex.clone(), + }; + + assert_eq!(whitelist.commands, commands); + assert_eq!(whitelist.regex, regex); + } + + #[test] + fn test_blacklist_creation() { + let commands = vec!["rm".to_string(), "dd".to_string()]; + let regex = vec![".*[|&].*".to_string(), "^sudo .*".to_string()]; + + let blacklist = Blacklist { + commands: commands.clone(), + regex: regex.clone(), + }; + + assert_eq!(blacklist.commands, commands); + assert_eq!(blacklist.regex, regex); + } + + #[test] + fn test_settings_creation() { + let settings = Settings { + port: 8080, + host: "localhost".to_string(), + env: Some("production".to_string()), + }; + + assert_eq!(settings.port, 8080); + assert_eq!(settings.host, "localhost"); + assert_eq!(settings.env, Some("production".to_string())); + } +} diff --git a/mcp-servers/mcp-bash-server/src/common/mod.rs b/mcp-servers/mcp-bash-server/src/common/mod.rs new file mode 100644 index 00000000000..562e2bb0073 --- /dev/null +++ b/mcp-servers/mcp-bash-server/src/common/mod.rs @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +//! Common utilities and shared components for the MCP Bash Server +//! +//! This module contains the core functionality including: +//! - Bash command execution server implementation +//! - Configuration management +//! - OAuth2 authentication system +//! - Command validation and security enforcement + +pub mod bash_server; +pub mod config; +pub mod oauth; +pub mod validator; diff --git a/mcp-servers/mcp-bash-server/src/common/oauth.rs b/mcp-servers/mcp-bash-server/src/common/oauth.rs new file mode 100644 index 00000000000..5956688309a --- /dev/null +++ b/mcp-servers/mcp-bash-server/src/common/oauth.rs @@ -0,0 +1,1032 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +//! OAuth2 authentication implementation for MCP server +//! +//! This module provides OAuth2 authentication capabilities including: +//! - Client registration and validation +//! - Authorization code flow +//! - Token management and validation +//! - Session management for auth flows +//! - Middleware for request authentication + +use std::{collections::HashMap, sync::Arc}; + +use askama::Template; +use axum::{ + Json, + body::Body, + extract::{Form, Query, State}, + http::{HeaderMap, Request, StatusCode}, + middleware::Next, + response::{Html, IntoResponse, Redirect, Response}, +}; +use chrono; +use oauth2::{AccessToken, EmptyExtraTokenFields, RefreshToken, StandardTokenResponse}; +use rand::{Rng, distributions::Alphanumeric}; +use rmcp::serde_json::{self, Value}; +use rmcp::transport::auth::{ + AuthorizationMetadata, ClientRegistrationRequest, ClientRegistrationResponse, OAuthClientConfig, +}; +use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; +use tracing::{debug, error, info, warn}; +use uuid::Uuid; + +/// Type alias for OAuth2 standard token response +/// Type alias for OAuth2 standard token response +pub type AuthToken = StandardTokenResponse; + +/// Centralized OAuth store for managing clients, sessions, and tokens +/// Provides thread-safe access to OAuth-related data structures +#[derive(Clone, Debug)] +pub struct McpOAuthStore { + /// Registered OAuth clients with their configurations + pub clients: Arc>>, + /// Active authorization sessions indexed by session ID + pub auth_sessions: Arc>>, + /// Valid access tokens indexed by token string + pub access_tokens: Arc>>, +} + +impl McpOAuthStore { + /// Create a new OAuth store with a default client configuration + pub fn new() -> Self { + let mut clients = HashMap::new(); + clients.insert( + "mcp-client".to_string(), + OAuthClientConfig { + client_id: "mcp-client".to_string(), + client_secret: Some("mcp-client-secret".to_string()), + scopes: vec!["profile".to_string(), "email".to_string()], + redirect_uri: "http://localhost:8080/callback".to_string(), + }, + ); + + Self { + clients: Arc::new(RwLock::new(clients)), + auth_sessions: Arc::new(RwLock::new(HashMap::new())), + access_tokens: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Validate client credentials and redirect URI + /// Returns Some(client_config) if valid, None otherwise + pub async fn validate_client( + &self, + client_id: &str, + redirect_uri: &str, + ) -> Option { + let clients = self.clients.read().await; + if let Some(client) = clients.get(client_id) { + info!("client.redirect_uri: {}", client.redirect_uri); + info!("redirect_uri: {redirect_uri}"); + if client.redirect_uri == redirect_uri { + return Some(client.clone()); + } + } else { + error!("Invalid client_id: {client_id}"); + } + None + } + + /// Create a new authorization session for the OAuth flow + /// Returns the session ID for tracking the auth process + pub async fn create_auth_session( + &self, + client_id: String, + scope: Option, + state: Option, + session_id: String, + ) -> String { + let session = AuthSession { + client_id, + scope, + _state: state, + _created_at: chrono::Utc::now(), + auth_token: None, + }; + + self.auth_sessions + .write() + .await + .insert(session_id.clone(), session); + session_id + } + + /// Update an authorization session with a generated token + /// Links the OAuth token to the session for later retrieval + pub async fn update_auth_session_token( + &self, + session_id: &str, + token: AuthToken, + ) -> Result<(), String> { + let mut sessions = self.auth_sessions.write().await; + if let Some(session) = sessions.get_mut(session_id) { + session.auth_token = Some(token); + Ok(()) + } else { + Err("Session not found".to_string()) + } + } + + /// Create a new MCP access token linked to an authorization session + /// Returns the generated McpAccessToken on success + pub async fn create_mcp_token(&self, session_id: &str) -> Result { + let sessions = self.auth_sessions.read().await; + if let Some(session) = sessions.get(session_id) { + if let Some(auth_token) = &session.auth_token { + let access_token = format!("mcp-token-{}", Uuid::new_v4()); + let refresh_token = format!("mcp-refresh-{}", Uuid::new_v4()); + + let token = McpAccessToken { + access_token: access_token.clone(), + token_type: "Bearer".to_string().to_lowercase(), + expires_in: Some(3600), + refresh_token: Some(refresh_token), + scope: session.scope.clone(), + auth_token: auth_token.clone(), + client_id: session.client_id.clone(), + }; + + self.access_tokens + .write() + .await + .insert(access_token.clone(), token.clone()); + Ok(token) + } else { + Err("No third-party token available for session".to_string()) + } + } else { + Err("Session not found".to_string()) + } + } + + /// Validate an access token and return the associated McpAccessToken if valid + pub async fn validate_token(&self, token: &str) -> Option { + self.access_tokens.read().await.get(token).cloned() + } +} + +/// Authorization session data structure +/// Tracks ongoing OAuth authorization flows with client and state information +#[derive(Clone, Debug)] +pub struct AuthSession { + pub client_id: String, + pub scope: Option, + pub _state: Option, + pub _created_at: chrono::DateTime, + pub auth_token: Option, +} + +/// MCP-specific access token structure +/// Wraps OAuth2 standard tokens with additional MCP metadata +#[derive(Clone, Debug, Serialize)] +pub struct McpAccessToken { + pub access_token: String, + pub token_type: String, + pub expires_in: Option, + pub refresh_token: Option, + pub scope: Option, + pub auth_token: AuthToken, + pub client_id: String, +} + +/// OAuth authorization request parameters +/// Contains all required fields for initiating an OAuth authorization flow +#[derive(Debug, Deserialize)] +pub struct AuthorizeQuery { + #[allow(dead_code)] + pub response_type: String, + pub client_id: String, + pub redirect_uri: String, + pub scope: Option, + pub state: Option, +} + +/// OAuth token request parameters +/// Used for exchanging authorization codes for access tokens +#[derive(Debug, Deserialize, Serialize)] +pub struct TokenRequest { + pub grant_type: String, + #[serde(default)] + pub code: String, + #[serde(default)] + pub client_id: String, + #[serde(default)] + pub client_secret: String, + #[serde(default)] + pub redirect_uri: String, + #[serde(default)] + pub code_verifier: Option, + #[serde(default)] + pub refresh_token: String, +} + +/// User information structure for OAuth responses +/// Contains standard user profile data +#[derive(Debug, Deserialize, Serialize)] +pub struct UserInfo { + pub sub: String, + pub name: String, + pub email: String, + pub username: String, +} + +/// Template context for OAuth authorization page +/// Contains all data needed to render the authorization consent form +#[derive(Template)] +#[template(path = "mcp_oauth_authorize.html")] +pub struct OAuthAuthorizeTemplate { + pub client_id: String, + pub redirect_uri: String, + pub scope: String, + pub state: String, + pub scopes: String, +} + +/// Form data for user authorization approval +/// Contains user's decision and associated OAuth parameters +#[derive(Debug, Deserialize)] +pub struct ApprovalForm { + pub client_id: String, + pub redirect_uri: String, + pub scope: String, + pub state: String, + pub approved: String, +} + +/// Generate a cryptographically secure random string +/// Used for creating client secrets and other security tokens +pub fn generate_random_string(length: usize) -> String { + rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(length) + .map(char::from) + .collect() +} + +/// OAuth authorization endpoint handler +/// Displays the authorization consent page to users +pub async fn oauth_authorize( + Query(params): Query, + State(state): State>, +) -> impl IntoResponse { + debug!("doing oauth_authorize"); + if let Some(_client) = state + .validate_client(¶ms.client_id, ¶ms.redirect_uri) + .await + { + let template = OAuthAuthorizeTemplate { + client_id: params.client_id, + redirect_uri: params.redirect_uri, + scope: params.scope.clone().unwrap_or_default(), + state: params.state.clone().unwrap_or_default(), + scopes: params + .scope + .clone() + .unwrap_or_else(|| "Basic scope".to_string()), + }; + + Html(template.render().unwrap()).into_response() + } else { + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_request", + "error_description": "invalid client id or redirect uri" + })), + ) + .into_response() + } +} + +/// Handle user approval/rejection of OAuth authorization +/// Processes the consent form and generates authorization codes +pub async fn oauth_approve( + State(state): State>, + Form(form): Form, +) -> impl IntoResponse { + if form.approved != "true" { + // user rejected the authorization request + let redirect_url = format!( + "{}?error=access_denied&error_description={}{}", + form.redirect_uri, + "user rejected the authorization request", + if form.state.is_empty() { + "".to_string() + } else { + format!("&state={}", form.state) + } + ); + return Redirect::to(&redirect_url).into_response(); + } + + // user approved the authorization request, generate authorization code + let session_id = Uuid::new_v4().to_string(); + let auth_code = format!("mcp-code-{session_id}"); + + // create new session record authorization information + let session_id = state + .create_auth_session( + form.client_id.clone(), + Some(form.scope.clone()), + Some(form.state.clone()), + session_id.clone(), + ) + .await; + + // create token using oauth2 standard + let access_token = AccessToken::new(format!("tp-token-{}", Uuid::new_v4())); + let refresh_token = RefreshToken::new(format!("tp-refresh-{}", Uuid::new_v4())); + let token_type = oauth2::basic::BasicTokenType::Bearer; + + let mut created_token = + StandardTokenResponse::new(access_token, token_type, EmptyExtraTokenFields {}); + created_token.set_expires_in(Some(&std::time::Duration::from_secs(3600))); + created_token.set_refresh_token(Some(refresh_token)); + created_token.set_scopes(Some(vec![oauth2::Scope::new(form.scope.clone())])); + + // update session token + if let Err(e) = state + .update_auth_session_token(&session_id, created_token) + .await + { + error!("Failed to update session token: {}", e); + } + + // redirect back to client, with authorization code + let redirect_url = format!( + "{}?code={}{}", + form.redirect_uri, + auth_code, + if form.state.is_empty() { + "".to_string() + } else { + format!("&state={}", form.state) + } + ); + + info!("authorization approved, redirecting to: {}", redirect_url); + Redirect::to(&redirect_url).into_response() +} + +/// OAuth token endpoint handler +/// Exchanges authorization codes for access tokens +pub async fn oauth_token( + State(state): State>, + request: axum::http::Request, +) -> impl IntoResponse { + info!("Received token request"); + + let bytes = match axum::body::to_bytes(request.into_body(), usize::MAX).await { + Ok(bytes) => bytes, + Err(e) => { + error!("can't read request body: {}", e); + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_request", + "error_description": "can't read request body" + })), + ) + .into_response(); + } + }; + + let body_str = String::from_utf8_lossy(&bytes); + info!("request body: {}", body_str); + + let token_req = match serde_urlencoded::from_bytes::(&bytes) { + Ok(form) => { + info!("successfully parsed form data: {:?}", form); + form + } + Err(e) => { + error!("can't parse form data: {}", e); + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(serde_json::json!({ + "error": "invalid_request", + "error_description": format!("can't parse form data: {}", e) + })), + ) + .into_response(); + } + }; + if token_req.grant_type == "refresh_token" { + warn!("this easy server only support authorization_code now"); + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "unsupported_grant_type", + "error_description": "only authorization_code is supported" + })), + ) + .into_response(); + } + if token_req.grant_type != "authorization_code" { + info!("unsupported grant type: {}", token_req.grant_type); + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "unsupported_grant_type", + "error_description": "only authorization_code is supported" + })), + ) + .into_response(); + } + + // get session_id from code + if !token_req.code.starts_with("mcp-code-") { + info!("invalid authorization code: {}", token_req.code); + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_grant", + "error_description": "invalid authorization code" + })), + ) + .into_response(); + } + + // handle empty client_id + let client_id = if token_req.client_id.is_empty() { + "mcp-client".to_string() + } else { + token_req.client_id.clone() + }; + + // validate client + match state + .validate_client(&client_id, &token_req.redirect_uri) + .await + { + Some(_) => { + let session_id = token_req.code.replace("mcp-code-", ""); + info!("got session id: {}", session_id); + + // create mcp access token + match state.create_mcp_token(&session_id).await { + Ok(token) => { + info!("successfully created access token"); + ( + StatusCode::OK, + Json(serde_json::json!({ + "access_token": token.access_token, + "token_type": token.token_type, + "expires_in": token.expires_in, + "refresh_token": token.refresh_token, + "scope": token.scope, + })), + ) + .into_response() + } + Err(e) => { + error!("failed to create access token: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "server_error", + "error_description": format!("failed to create access token: {}", e) + })), + ) + .into_response() + } + } + } + None => { + info!( + "invalid client id or redirect uri: {} / {}", + client_id, token_req.redirect_uri + ); + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_client", + "error_description": "invalid client id or redirect uri" + })), + ) + .into_response() + } + } +} + +/// Authentication middleware for validating Bearer tokens +/// Intercepts requests and validates access tokens before allowing access +pub async fn validate_token_middleware( + State(token_store): State>, + request: Request, + next: Next, +) -> Response { + debug!("validate_token_middleware"); + // Extract the access token from the Authorization header + let auth_header = request.headers().get("Authorization"); + let token = match auth_header { + Some(header) => { + let header_str = header.to_str().unwrap_or(""); + if let Some(stripped) = header_str.strip_prefix("Bearer ") { + stripped.to_string() + } else { + return StatusCode::UNAUTHORIZED.into_response(); + } + } + None => { + return StatusCode::UNAUTHORIZED.into_response(); + } + }; + + // Validate the token + match token_store.validate_token(&token).await { + Some(_) => next.run(request).await, + None => StatusCode::UNAUTHORIZED.into_response(), + } +} + +/// Get the actual IP address to use for endpoints +/// Returns the host from request headers if bind_address is 0.0.0.0, otherwise returns the original address +fn get_endpoint_address(bind_address: &str, host_header: Option<&str>) -> String { + if bind_address.starts_with("0.0.0.0") { + if let Some(host) = host_header { + // Use the Host header value, which contains the actual IP/domain the client used + host.to_string() + } else { + // Fallback to localhost if no Host header is present + bind_address.replacen("0.0.0.0", "localhost", 1) + } + } else { + bind_address.to_string() + } +} + +/// OAuth authorization server metadata endpoint +/// Returns server capabilities and endpoint URLs per RFC 8414 +pub async fn oauth_authorization_server( + bind_address: &str, + headers: HeaderMap, +) -> impl IntoResponse { + let host_header = headers.get("host").and_then(|h| h.to_str().ok()); + let endpoint_address = get_endpoint_address(bind_address, host_header); + + let mut additional_fields = HashMap::new(); + additional_fields.insert( + "response_types_supported".into(), + Value::Array(vec![Value::String("code".into())]), + ); + additional_fields.insert( + "code_challenge_methods_supported".into(), + Value::Array(vec![Value::String("S256".into())]), + ); + let metadata = AuthorizationMetadata { + authorization_endpoint: format!("http://{endpoint_address}/authorize"), + token_endpoint: format!("http://{endpoint_address}/token"), + scopes_supported: Some(vec!["profile".to_string(), "email".to_string()]), + registration_endpoint: format!("http://{endpoint_address}/register"), + issuer: Some(format!("http://{endpoint_address}")), + jwks_uri: Some(format!("http://{endpoint_address}/jwks")), + additional_fields, + }; + debug!("metadata: {:?}", metadata); + (StatusCode::OK, Json(metadata)) +} + +/// Dynamic client registration endpoint +/// Allows clients to register themselves with the OAuth server +pub async fn oauth_register( + State(state): State>, + Json(req): Json, +) -> impl IntoResponse { + debug!("register request: {:?}", req); + if req.redirect_uris.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_request", + "error_description": "at least one redirect uri is required" + })), + ) + .into_response(); + } + + // generate client id and secret + let client_id = format!("client-{}", Uuid::new_v4()); + let client_secret = generate_random_string(32); + + let client = OAuthClientConfig { + client_id: client_id.clone(), + client_secret: Some(client_secret.clone()), + redirect_uri: req.redirect_uris[0].clone(), + scopes: vec![], + }; + + state + .clients + .write() + .await + .insert(client_id.clone(), client); + + // return client information + let response = ClientRegistrationResponse { + client_id, + client_secret: Some(client_secret), + client_name: req.client_name, + redirect_uris: req.redirect_uris, + additional_fields: HashMap::new(), + }; + + (StatusCode::CREATED, Json(response)).into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_oauth_store() -> McpOAuthStore { + McpOAuthStore::new() + } + + #[tokio::test] + async fn test_oauth_store_creation() { + let store = create_test_oauth_store(); + + // Check that default client exists + let clients = store.clients.read().await; + assert!(clients.contains_key("mcp-client")); + + let default_client = clients.get("mcp-client").unwrap(); + assert_eq!(default_client.client_id, "mcp-client"); + assert_eq!( + default_client.client_secret, + Some("mcp-client-secret".to_string()) + ); + assert!(default_client.scopes.contains(&"profile".to_string())); + assert!(default_client.scopes.contains(&"email".to_string())); + } + + #[tokio::test] + async fn test_validate_client_success() { + let store = create_test_oauth_store(); + + let result = store + .validate_client("mcp-client", "http://localhost:8080/callback") + .await; + assert!(result.is_some()); + + let client = result.unwrap(); + assert_eq!(client.client_id, "mcp-client"); + } + + #[tokio::test] + async fn test_validate_client_invalid_client_id() { + let store = create_test_oauth_store(); + + let result = store + .validate_client("invalid-client", "http://localhost:8080/callback") + .await; + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_validate_client_invalid_redirect_uri() { + let store = create_test_oauth_store(); + + let result = store + .validate_client("mcp-client", "http://malicious.com/callback") + .await; + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_create_auth_session() { + let store = create_test_oauth_store(); + + let session_id = store + .create_auth_session( + "mcp-client".to_string(), + Some("profile email".to_string()), + Some("state123".to_string()), + "session123".to_string(), + ) + .await; + + assert_eq!(session_id, "session123"); + + // Verify session exists + let sessions = store.auth_sessions.read().await; + assert!(sessions.contains_key("session123")); + + let session = sessions.get("session123").unwrap(); + assert_eq!(session.client_id, "mcp-client"); + assert_eq!(session.scope, Some("profile email".to_string())); + assert!(session.auth_token.is_none()); + } + + #[tokio::test] + async fn test_update_auth_session_token() { + let store = create_test_oauth_store(); + + // Create session first + let session_id = store + .create_auth_session( + "mcp-client".to_string(), + Some("profile".to_string()), + None, + "session456".to_string(), + ) + .await; + + // Create a mock token + let token = AuthToken::new( + AccessToken::new("access_token_123".to_string()), + oauth2::basic::BasicTokenType::Bearer, + EmptyExtraTokenFields {}, + ); + + // Update session with token + let result = store.update_auth_session_token(&session_id, token).await; + assert!(result.is_ok()); + + // Verify token was added + let sessions = store.auth_sessions.read().await; + let session = sessions.get("session456").unwrap(); + assert!(session.auth_token.is_some()); + } + + #[tokio::test] + async fn test_update_auth_session_token_invalid_session() { + let store = create_test_oauth_store(); + + let token = AuthToken::new( + AccessToken::new("access_token_123".to_string()), + oauth2::basic::BasicTokenType::Bearer, + EmptyExtraTokenFields {}, + ); + + let result = store.update_auth_session_token("nonexistent", token).await; + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "Session not found"); + } + + #[tokio::test] + async fn test_create_mcp_token_success() { + let store = create_test_oauth_store(); + + // Create session and update with auth token + let session_id = store + .create_auth_session( + "mcp-client".to_string(), + Some("profile".to_string()), + None, + "session789".to_string(), + ) + .await; + + let auth_token = AuthToken::new( + AccessToken::new("third_party_token".to_string()), + oauth2::basic::BasicTokenType::Bearer, + EmptyExtraTokenFields {}, + ); + + store + .update_auth_session_token(&session_id, auth_token) + .await + .unwrap(); + + // Create MCP token + let result = store.create_mcp_token(&session_id).await; + assert!(result.is_ok()); + + let mcp_token = result.unwrap(); + assert!(mcp_token.access_token.starts_with("mcp-token-")); + assert!( + mcp_token + .refresh_token + .as_ref() + .unwrap() + .starts_with("mcp-refresh-") + ); + assert_eq!(mcp_token.token_type, "bearer"); + assert_eq!(mcp_token.expires_in, Some(3600)); + assert_eq!(mcp_token.scope, Some("profile".to_string())); + assert_eq!(mcp_token.client_id, "mcp-client"); + } + + #[tokio::test] + async fn test_create_mcp_token_no_session() { + let store = create_test_oauth_store(); + + let result = store.create_mcp_token("nonexistent").await; + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "Session not found"); + } + + #[tokio::test] + async fn test_create_mcp_token_no_auth_token() { + let store = create_test_oauth_store(); + + // Create session without auth token + let session_id = store + .create_auth_session( + "mcp-client".to_string(), + Some("profile".to_string()), + None, + "session_no_token".to_string(), + ) + .await; + + let result = store.create_mcp_token(&session_id).await; + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + "No third-party token available for session" + ); + } + + #[tokio::test] + async fn test_validate_token_success() { + let store = create_test_oauth_store(); + + // Create a complete flow to get a valid token + let session_id = store + .create_auth_session( + "mcp-client".to_string(), + Some("profile".to_string()), + None, + "token_test_session".to_string(), + ) + .await; + + let auth_token = AuthToken::new( + AccessToken::new("third_party_token".to_string()), + oauth2::basic::BasicTokenType::Bearer, + EmptyExtraTokenFields {}, + ); + + store + .update_auth_session_token(&session_id, auth_token) + .await + .unwrap(); + let mcp_token = store.create_mcp_token(&session_id).await.unwrap(); + + // Validate the token + let result = store.validate_token(&mcp_token.access_token).await; + assert!(result.is_some()); + + let validated_token = result.unwrap(); + assert_eq!(validated_token.access_token, mcp_token.access_token); + assert_eq!(validated_token.client_id, "mcp-client"); + } + + #[tokio::test] + async fn test_validate_token_invalid() { + let store = create_test_oauth_store(); + + let result = store.validate_token("invalid_token").await; + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_mcp_access_token_serialization() { + let auth_token = AuthToken::new( + AccessToken::new("test_token".to_string()), + oauth2::basic::BasicTokenType::Bearer, + EmptyExtraTokenFields {}, + ); + + let mcp_token = McpAccessToken { + access_token: "mcp-token-123".to_string(), + token_type: "bearer".to_string(), + expires_in: Some(3600), + refresh_token: Some("mcp-refresh-123".to_string()), + scope: Some("profile email".to_string()), + auth_token, + client_id: "test-client".to_string(), + }; + + // Test that it can be serialized to JSON + let json_result = serde_json::to_string(&mcp_token); + assert!(json_result.is_ok()); + + let json_str = json_result.unwrap(); + assert!(json_str.contains("mcp-token-123")); + assert!(json_str.contains("bearer")); + assert!(json_str.contains("3600")); + } + + #[tokio::test] + async fn test_auth_session_creation_with_minimal_data() { + let store = create_test_oauth_store(); + + let session_id = store + .create_auth_session( + "test-client".to_string(), + None, // No scope + None, // No state + "minimal_session".to_string(), + ) + .await; + + assert_eq!(session_id, "minimal_session"); + + let sessions = store.auth_sessions.read().await; + let session = sessions.get("minimal_session").unwrap(); + assert_eq!(session.client_id, "test-client"); + assert!(session.scope.is_none()); + assert!(session._state.is_none()); + assert!(session.auth_token.is_none()); + } + + #[tokio::test] + async fn test_concurrent_access() { + let store = Arc::new(create_test_oauth_store()); + + // Test concurrent session creation + let mut handles = vec![]; + for i in 0..10 { + let store_clone = store.clone(); + let handle = tokio::spawn(async move { + store_clone + .create_auth_session( + "mcp-client".to_string(), + Some("profile".to_string()), + None, + format!("concurrent_session_{i}"), + ) + .await + }); + handles.push(handle); + } + + for handle in handles { + let session_id = handle.await.unwrap(); + assert!(session_id.starts_with("concurrent_session_")); + } + + // Verify all sessions were created + let sessions = store.auth_sessions.read().await; + assert_eq!(sessions.len(), 10); + } + #[test] + fn test_get_endpoint_address_with_zero_ip() { + let result = get_endpoint_address("0.0.0.0:8080", Some("192.168.1.100:8080")); + assert_eq!(result, "192.168.1.100:8080"); + } + + #[test] + fn test_get_endpoint_address_with_zero_ip_no_port() { + let result = get_endpoint_address("0.0.0.0", Some("192.168.1.100")); + assert_eq!(result, "192.168.1.100"); + } + + #[test] + fn test_get_endpoint_address_with_zero_ip_no_host_header() { + let result = get_endpoint_address("0.0.0.0:8080", None); + assert_eq!(result, "localhost:8080"); + } + + #[test] + fn test_get_endpoint_address_with_specific_ip() { + let result = get_endpoint_address("192.168.1.100:8080", Some("192.168.1.100:8080")); + assert_eq!(result, "192.168.1.100:8080"); + } + + #[test] + fn test_get_endpoint_address_with_localhost() { + let result = get_endpoint_address("localhost:8080", Some("localhost:8080")); + assert_eq!(result, "localhost:8080"); + } + + #[test] + fn test_get_endpoint_address_with_domain() { + let result = get_endpoint_address("example.com:8080", Some("example.com:8080")); + assert_eq!(result, "example.com:8080"); + } + + #[tokio::test] + async fn test_oauth_authorization_server_with_zero_ip() { + use axum::http::HeaderMap; + + let mut headers = HeaderMap::new(); + headers.insert("host", "192.168.1.100:8080".parse().unwrap()); + + let _response = oauth_authorization_server("0.0.0.0:8080", headers).await; + + // This is a basic test to ensure the function doesn't panic + // In a real test, you'd want to extract and verify the JSON response + // to ensure the URLs contain "192.168.1.100:8080" instead of "0.0.0.0:8080" + } +} diff --git a/mcp-servers/mcp-bash-server/src/common/validator.rs b/mcp-servers/mcp-bash-server/src/common/validator.rs new file mode 100644 index 00000000000..531d29a5130 --- /dev/null +++ b/mcp-servers/mcp-bash-server/src/common/validator.rs @@ -0,0 +1,410 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +//! Command validation module for security enforcement +//! +//! This module provides command validation functionality to prevent execution +//! of dangerous commands and operations based on configurable blacklists. + +use tracing::debug; + +use crate::{common::config::Whitelist, config::Blacklist}; +use rmcp::model::ErrorData; +use tracing::error; + +/// Command validator that checks commands against security blacklists +/// Prevents execution of dangerous commands and operations +#[derive(Debug, Clone)] +pub struct Validator { + /// Security blacklist configuration containing forbidden commands and operations + blacklist: Blacklist, + /// Security whitelist configuration containing secure commands + whitelist: Whitelist, +} + +impl Validator { + /// Create a new validator with the specified blacklist configuration + pub fn new(blacklist: Blacklist, whitelist: Whitelist) -> Self { + Validator { + blacklist, + whitelist, + } + } + + /// Validate a command against the security blacklist and whitelist + /// Returns Ok(()) if command is safe, Err(ErrorData) if command is blocked + /// + /// Security Logic: + /// 1. All commands are denied by default + /// 2. Blacklist has higher priority than whitelist + /// 3. If command matches any blacklist pattern (exact or regex), deny + /// 4. If command doesn't match blacklist and matches whitelist (exact or regex), allow + /// 5. Otherwise, deny + pub fn is_unsafe_command(&self, args: &str) -> Result<(), ErrorData> { + // Handle empty command - deny by default + if args.is_empty() { + return Err(ErrorData::invalid_request( + "Empty command not allowed".to_string(), + None, + )); + } + + let full_cmd = args.to_string(); + + debug!("Validating command: {}", full_cmd); + + // First check blacklist - if any match, deny immediately + // Check blacklist exact commands - match against the full command + for blacklisted_cmd in &self.blacklist.commands { + if &full_cmd == blacklisted_cmd { + error!( + "Command blocked by blacklist exact match: {}, full command: {}", + blacklisted_cmd, full_cmd + ); + return Err(ErrorData::invalid_request( + format!("Command blocked by blacklist: {blacklisted_cmd}"), + None, + )); + } + } + + // Check blacklist regex patterns + for pattern in &self.blacklist.regex { + match regex::Regex::new(pattern) { + Ok(regex) => { + if regex.is_match(&full_cmd) { + error!( + "Command blocked by blacklist regex pattern: {}, full command: {}", + pattern, full_cmd + ); + return Err(ErrorData::invalid_request( + format!("Command blocked by blacklist pattern: {pattern}"), + None, + )); + } + } + Err(e) => { + error!( + "Invalid regex pattern in blacklist: {}, error: {}", + pattern, e + ); + } + } + } + + // Now check whitelist - if any match, allow + // Check whitelist exact commands + if self.whitelist.commands.contains(&full_cmd) { + debug!("Command allowed by whitelist exact match: {}", full_cmd); + return Ok(()); + } + + // Check whitelist regex patterns + for pattern in &self.whitelist.regex { + match regex::Regex::new(pattern) { + Ok(regex) => { + if regex.is_match(&full_cmd) { + debug!("Command allowed by whitelist regex pattern: {}", pattern); + return Ok(()); + } + } + Err(e) => { + error!( + "Invalid regex pattern in whitelist: {}, error: {}", + pattern, e + ); + } + } + } + + // Default deny - command didn't match whitelist + error!("Command denied - not in whitelist: {full_cmd}"); + Err(ErrorData::invalid_request( + format!("Command not allowed: {full_cmd}"), + None, + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_validator() -> Validator { + let blacklist = Blacklist { + commands: vec![ + "rm".to_string(), + "dd".to_string(), + "shutdown".to_string(), + "kill".to_string(), + ], + regex: vec![ + ".*[|&;><].*".to_string(), // Block commands with dangerous operators + "^sudo .*".to_string(), // Block sudo commands + ".*/etc/.*".to_string(), // Block access to /etc + ".*passwd.*".to_string(), // Block password-related commands + ], + }; + + let whitelist = Whitelist { + commands: vec![ + "echo hello".to_string(), + "ls -la".to_string(), + "pwd".to_string(), + ], + regex: vec![ + "^echo [a-zA-Z0-9 ]+$".to_string(), // Only simple echo commands + "^ls [a-zA-Z0-9 /-]*$".to_string(), // Only simple ls commands + "^(pwd|whoami|date|uptime)$".to_string(), // Basic system info commands + ], + }; + + Validator::new(blacklist, whitelist) + } + + #[test] + fn test_validator_creation() { + let validator = create_test_validator(); + assert_eq!(validator.blacklist.commands.len(), 4); + assert_eq!(validator.blacklist.regex.len(), 4); + assert_eq!(validator.whitelist.commands.len(), 3); + assert_eq!(validator.whitelist.regex.len(), 3); + } + + #[test] + fn test_safe_command() { + let validator = create_test_validator(); + let result = validator.is_unsafe_command("pwd"); + assert!(result.is_ok()); // pwd is whitelisted + } + + #[test] + fn test_whitelisted_command() { + let validator = create_test_validator(); + let result = validator.is_unsafe_command("echo hello"); + assert!(result.is_ok()); // "echo hello" is exactly whitelisted + } + + #[test] + fn test_whitelisted_regex_command() { + let validator = create_test_validator(); + let result = validator.is_unsafe_command("echo test123"); + assert!(result.is_ok()); // Matches whitelist regex "^echo [a-zA-Z0-9 ]+$" + } + + #[test] + fn test_blacklisted_command() { + let validator = create_test_validator(); + // Test a command that contains a blacklisted command but isn't exact match + let result = validator.is_unsafe_command("rm -rf /"); + // Should be denied because "rm -rf /" is not in whitelist (default deny) + // not because "rm" is blacklisted (since we need exact command match) + assert!(result.is_err()); + } + + #[test] + fn test_blacklisted_exact_command() { + let validator = create_test_validator(); + // Test exact match against blacklist + let result = validator.is_unsafe_command("rm"); + assert!(result.is_err()); // "rm" exactly matches blacklist + } + + #[test] + fn test_blacklisted_regex_operation() { + let validator = create_test_validator(); + // Test pipe operation which should be blocked by regex + let result = validator.is_unsafe_command("echo test | cat"); + assert!(result.is_err()); // Contains "|" which matches ".*[|&;><].*" regex + } + + #[test] + fn test_multiple_blacklisted_commands() { + let validator = create_test_validator(); + + // Test commands that are exactly in the blacklist + let dangerous_commands = vec!["rm", "dd", "shutdown", "kill"]; + for cmd in dangerous_commands { + let result = validator.is_unsafe_command(cmd); + assert!(result.is_err(), "Command '{cmd}' should be blocked"); + } + } + + #[test] + fn test_multiple_blacklisted_regex_operations() { + let validator = create_test_validator(); + + let dangerous_ops = vec!["|", "&", ";", ">"]; + for op in dangerous_ops { + let cmd = format!("echo test {op}"); + let result = validator.is_unsafe_command(&cmd); + assert!( + result.is_err(), + "Operation '{op}' should be blocked by regex" + ); + } + } + + #[test] + fn test_empty_command() { + let validator = create_test_validator(); + let result = validator.is_unsafe_command(""); + assert!(result.is_err()); // Empty commands are now denied by default + } + + #[test] + fn test_complex_safe_command() { + let validator = create_test_validator(); + let result = validator.is_unsafe_command("find /tmp -name *.txt"); + assert!(result.is_err()); // Not in whitelist, so denied by default + } + + #[test] + fn test_invalid_regex_in_whitelist() { + let blacklist = Blacklist { + commands: vec![], + regex: vec![], + }; + + let whitelist = Whitelist { + commands: vec![], + regex: vec!["[invalid_regex".to_string()], // Invalid regex + }; + + let validator = Validator::new(blacklist, whitelist); + let result = validator.is_unsafe_command("test"); + // Should be denied because command is not in whitelist and default is deny + assert!(result.is_err()); + } + + #[test] + fn test_case_sensitivity() { + let validator = create_test_validator(); + + // Test uppercase version of blacklisted command + let result = validator.is_unsafe_command("RM"); + // Should be denied because not in whitelist (default deny) + assert!(result.is_err()); + + // Test exact case match for blacklisted command + let result = validator.is_unsafe_command("rm"); + assert!(result.is_err()); // Should be blocked by blacklist + } + + #[test] + fn test_partial_command_match() { + let validator = create_test_validator(); + + // Test command that contains blacklisted word but isn't exact match + let result = validator.is_unsafe_command("remove"); // contains "rm" but shouldn't match + assert!(result.is_err()); // Should be denied because not in whitelist (default deny) + } + + #[test] + fn test_blacklist_priority_over_whitelist() { + // Test that blacklist has higher priority than whitelist + let blacklist = Blacklist { + commands: vec!["echo hello".to_string()], // Changed to exact match + regex: vec![], + }; + + let whitelist = Whitelist { + commands: vec!["echo hello".to_string()], + regex: vec!["^echo .*".to_string()], + }; + + let validator = Validator::new(blacklist, whitelist); + let result = validator.is_unsafe_command("echo hello"); + assert!(result.is_err()); // Should be blocked because "echo hello" is in blacklist + } + + #[test] + fn test_blacklist_regex_priority() { + // Test that blacklist regex has higher priority than whitelist + let blacklist = Blacklist { + commands: vec![], + regex: vec![".*sudo.*".to_string()], + }; + + let whitelist = Whitelist { + commands: vec!["sudo ls".to_string()], + regex: vec!["^sudo .*".to_string()], + }; + + let validator = Validator::new(blacklist, whitelist); + let result = validator.is_unsafe_command("sudo ls"); + assert!(result.is_err()); // Should be blocked by blacklist regex + } + + #[test] + fn test_default_deny_behavior() { + // Test that commands not in whitelist are denied by default + let blacklist = Blacklist { + commands: vec![], + regex: vec![], + }; + + let whitelist = Whitelist { + commands: vec!["pwd".to_string()], + regex: vec![], + }; + + let validator = Validator::new(blacklist, whitelist); + + // Command in whitelist should be allowed + let result = validator.is_unsafe_command("pwd"); + assert!(result.is_ok()); + + // Command not in whitelist should be denied + let result = validator.is_unsafe_command("ls"); + assert!(result.is_err()); + } + + #[test] + fn test_exact_vs_partial_blacklist_matching() { + // Test the fix: blacklist should match exactly, not partially + let blacklist = Blacklist { + commands: vec!["rm".to_string()], // Only "rm" exactly + regex: vec![], + }; + + let whitelist = Whitelist { + commands: vec!["rm -rf /tmp/test".to_string()], // Allow this specific command + regex: vec![], + }; + + let validator = Validator::new(blacklist, whitelist); + + // "rm" exactly should be blocked by blacklist + let result = validator.is_unsafe_command("rm"); + assert!( + result.is_err(), + "Exact match 'rm' should be blocked by blacklist" + ); + + // "rm -rf /tmp/test" should be allowed by whitelist (not blocked by blacklist) + let result = validator.is_unsafe_command("rm -rf /tmp/test"); + assert!( + result.is_ok(), + "'rm -rf /tmp/test' should be allowed by whitelist" + ); + + // "rm -rf /" should be denied (not in whitelist, not exact blacklist match) + let result = validator.is_unsafe_command("rm -rf /"); + assert!(result.is_err(), "'rm -rf /' should be denied by default"); + } +} diff --git a/mcp-servers/mcp-bash-server/src/html/mcp_oauth_index.html b/mcp-servers/mcp-bash-server/src/html/mcp_oauth_index.html new file mode 100644 index 00000000000..76afe635ef3 --- /dev/null +++ b/mcp-servers/mcp-bash-server/src/html/mcp_oauth_index.html @@ -0,0 +1,82 @@ + + + + + + MCP OAuth Server + + + +

MCP OAuth Server

+

This is an MCP server with OAuth 2.0 integration to a third-party authorization server.

+ +

Available Endpoints:

+ +
+

Authorization Endpoint

+

GET /authorize

+

Parameters:

+
    +
  • response_type - Must be "code"
  • +
  • client_id - Client identifier (e.g., "mcp-client")
  • +
  • redirect_uri - URI to redirect after authorization
  • +
  • scope - Optional requested scope
  • +
  • state - Optional state value for CSRF prevention
  • +
+
+ +
+

Token Endpoint

+

POST /token

+

Parameters:

+
    +
  • grant_type - Must be "authorization_code"
  • +
  • code - The authorization code
  • +
  • client_id - Client identifier
  • +
  • client_secret - Client secret
  • +
  • redirect_uri - Redirect URI used in authorization request
  • +
+
+ +
+

MCP streamablehttp Endpoints

+

/mcp - Streamablehttp connection endpoint (requires OAuth token)

+
+ +
+

OAuth Flow:

+
    +
  1. MCP Client initiates OAuth flow with this MCP Server
  2. +
  3. MCP Server redirects to Third-Party OAuth Server
  4. +
  5. User authenticates with Third-Party Server
  6. +
  7. Third-Party Server redirects back to MCP Server with auth code
  8. +
  9. MCP Server exchanges the code for a third-party access token
  10. +
  11. MCP Server generates its own token bound to the third-party session
  12. +
  13. MCP Server completes the OAuth flow with the MCP Client
  14. +
+
+ + diff --git a/mcp-servers/mcp-bash-server/src/main.rs b/mcp-servers/mcp-bash-server/src/main.rs new file mode 100644 index 00000000000..0611b72a55d --- /dev/null +++ b/mcp-servers/mcp-bash-server/src/main.rs @@ -0,0 +1,618 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +//! MCP Bash Server - A Model Context Protocol server for executing bash commands +//! +//! This server provides secure bash command execution capabilities through the MCP protocol. +//! It includes OAuth2 authentication, command validation, and cross-platform support. +//! +//! Features: +//! - Secure command execution with blacklist validation +//! - OAuth2 authentication for client authorization +//! - Cross-platform shell support (Linux, Windows, macOS) +//! - Built-in system information tools +//! - Configurable timeout and environment settings + +use std::sync::OnceLock; +use std::{net::SocketAddr, sync::Arc}; + +use anyhow::Result; +use axum::{ + Router, + body::Body, + http::{HeaderMap, Request}, + middleware::{self, Next}, + response::{Html, IntoResponse, Response}, + routing::{get, post}, +}; +use rmcp::transport::streamable_http_server::{ + StreamableHttpService, session::local::LocalSessionManager, +}; +use tower_http::cors::{Any, CorsLayer}; +use tracing::info; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +// Import modules +mod common; +use common::bash_server::BashServer; +use common::config; +use common::oauth::{ + McpOAuthStore, oauth_approve, oauth_authorization_server, oauth_authorize, oauth_register, + oauth_token, validate_token_middleware, +}; + +const INDEX_HTML: &str = include_str!("html/mcp_oauth_index.html"); + +/// Global storage for server bind address, initialized once at startup +// Init once from environment variable BIND_ADDRESS +pub static BIND_ADDRESS: OnceLock = OnceLock::new(); + +/// Root path handler +/// Serves the main OAuth authorization index page +async fn index() -> Html<&'static str> { + Html(INDEX_HTML) +} + +/// Wrapper function for oauth_authorization_server to handle BIND_ADDRESS +async fn oauth_authorization_server_handler(headers: HeaderMap) -> impl IntoResponse { + let bind_address = BIND_ADDRESS + .get() + .expect("BIND_ADDRESS must be initialized in main()"); + oauth_authorization_server(bind_address, headers).await +} + +/// HTTP request logging middleware +/// Logs all incoming requests including method, URI, headers and response status +async fn log_request(request: Request, next: Next) -> Response { + let method = request.method().clone(); + let uri = request.uri().clone(); + let version = request.version(); + + // Log headers + let headers = request.headers().clone(); + let mut header_log = String::new(); + for (key, value) in headers.iter() { + let value_str = value.to_str().unwrap_or(""); + header_log.push_str(&format!("\n {key}: {value_str}")); + } + + // Try to get request body for form submissions + let content_type = headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + let request_info = if content_type.contains("application/x-www-form-urlencoded") + || content_type.contains("application/json") + { + format!("{method} {uri} {version:?}{header_log}\nContent-Type: {content_type}") + } else { + format!("{method} {uri} {version:?}{header_log}") + }; + + info!("REQUEST: {}", request_info); + + // Call the actual handler + let response = next.run(request).await; + + // Log response status + let status = response.status(); + info!("RESPONSE: {} for {} {}", status, method, uri); + + response +} + +/// Main application entry point +/// Sets up logging, OAuth store, HTTP server, and starts the MCP bash server +#[tokio::main] +async fn main() -> Result<()> { + // Initialize logging + let logs = tracing_appender::rolling::daily("logs", "mcp.log"); + let (non_blocking, _guard) = tracing_appender::non_blocking(logs); + let log_setting = tracing_subscriber::fmt::layer().with_writer(non_blocking); + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "debug".to_string().into()), + ) + .with(tracing_subscriber::fmt::layer()) + .with(log_setting) + .init(); + + // Read environment mode from config file, default to "production" + let config = config::Config::read_config("config.toml")?; + let env_mode = config + .settings + .env + .clone() + .unwrap_or_else(|| "production".to_string()); + let is_dev = env_mode == "development"; + + // Create the OAuth store + let oauth_store = Arc::new(McpOAuthStore::new()); + + let host = config.settings.host.clone(); + let port = config.settings.port; + let bind_address = format!("{host}:{port}"); + + let addr = bind_address.parse::()?; + let _ = BIND_ADDRESS.set(bind_address); + + // Create StreamableHttpServer + let service = StreamableHttpService::new( + || Ok(BashServer::new()), + LocalSessionManager::default().into(), + Default::default(), + ); + + let server_router = Router::new().nest_service("/mcp", service); + + // Add OAuth authentication middleware only if not in development mode + let protected_server_router = if is_dev { + server_router + } else { + server_router.layer(middleware::from_fn_with_state( + oauth_store.clone(), + validate_token_middleware, + )) + }; + + // Create CORS layer for the oauth authorization server endpoint + let cors_layer = CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any); + + // Create a sub-router for the oauth authorization server endpoint with CORS + let oauth_server_router = Router::new() + .route( + "/.well-known/oauth-authorization-server", + get(oauth_authorization_server_handler).options(oauth_authorization_server_handler), + ) + .route("/token", post(oauth_token).options(oauth_token)) + .route("/register", post(oauth_register).options(oauth_register)) + .layer(cors_layer) + .with_state(oauth_store.clone()); + + // Create HTTP router with request logging middleware + let app = Router::new() + .route("/", get(index)) + .route("/authorize", get(oauth_authorize)) + .route("/approve", post(oauth_approve)) + .merge(oauth_server_router) // Merge the CORS-enabled oauth server router + .merge(protected_server_router) + .with_state(oauth_store.clone()) + .layer(middleware::from_fn(log_request)); + + // Start HTTP server + info!("MCP OAuth Server started on {}", addr); + let listener = tokio::net::TcpListener::bind(addr).await?; + let _ = axum::serve(listener, app) + .with_graceful_shutdown(async { tokio::signal::ctrl_c().await.unwrap() }) + .await; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::Method; + use axum::http::Request; + + #[tokio::test] + async fn test_index_handler() { + let response = index().await; + let html_content = response.0; + + // Verify it returns the expected HTML content + assert_eq!(html_content, INDEX_HTML); + assert!(html_content.contains("OAuth")); + } + + #[tokio::test] + async fn test_oauth_authorization_server_handler() { + use axum::http::HeaderMap; + + // Set up BIND_ADDRESS for testing + let _ = BIND_ADDRESS.set("localhost:8080".to_string()); + + let mut headers = HeaderMap::new(); + headers.insert("host", "localhost:8080".parse().unwrap()); + + let response = oauth_authorization_server_handler(headers).await; + + // Test that the handler returns a response + // We can't easily test the exact content without mocking, but we can verify it doesn't panic + let _response_body = response.into_response(); + } + + #[test] + fn test_bind_address_initialization() { + // Create a new OnceLock for testing to avoid conflicts + let test_bind_address: OnceLock = OnceLock::new(); + + // Test that we can set the value once + let result = test_bind_address.set("127.0.0.1:9090".to_string()); + assert!(result.is_ok()); + + // Test that we can get the value + let value = test_bind_address.get(); + assert!(value.is_some()); + assert_eq!(value.unwrap(), "127.0.0.1:9090"); + + // Test that we can't set it again + let result2 = test_bind_address.set("different:port".to_string()); + assert!(result2.is_err()); + } + + #[test] + fn test_index_html_constant() { + // Test that INDEX_HTML is not empty and contains expected content + assert!(INDEX_HTML.contains("html") || INDEX_HTML.contains("HTML")); + } + + #[tokio::test] + async fn test_log_request_middleware_functionality() { + // Test basic properties of log_request function + // Since it requires complex setup with actual middleware, + // we focus on testing the types and structure + + let request = Request::builder() + .method(Method::GET) + .uri("/test") + .body(Body::empty()) + .unwrap(); + + // Verify request properties that log_request would process + assert_eq!(request.method(), Method::GET); + assert_eq!(request.uri().path(), "/test"); + assert!(request.headers().is_empty()); + } + + #[test] + fn test_module_imports() { + // Test that our modules are properly imported and accessible + let _server = BashServer::new(); + let _store = McpOAuthStore::new(); + + // Test config module + let config_result = config::Config::read_config("nonexistent.toml"); + assert!(config_result.is_err()); // Should fail gracefully + } + + #[test] + fn test_error_handling_types() { + // Test that Result type is properly used + let test_result: Result = Ok("test".to_string()); + assert!(test_result.is_ok()); + + let test_error: Result = Err(anyhow::anyhow!("test error")); + assert!(test_error.is_err()); + } + + #[test] + fn test_dependencies_availability() { + // Test that critical dependencies are available + use std::sync::Arc; + + let _arc_store = Arc::new(McpOAuthStore::new()); + + // Test that we can create basic types + let _socket_addr: Result = "127.0.0.1:8080".parse(); + } + + // ========== OAuth Mock Tests ========== + + #[tokio::test] + async fn test_oauth_store_functionality() { + use common::oauth::{AuthToken, McpOAuthStore}; + use oauth2::{AccessToken, EmptyExtraTokenFields}; + + let store = McpOAuthStore::new(); + + // Test client validation + let client = store + .validate_client("mcp-client", "http://localhost:8080/callback") + .await; + assert!(client.is_some()); + + let invalid_client = store + .validate_client("invalid-client", "http://localhost:8080/callback") + .await; + assert!(invalid_client.is_none()); + + // Test auth session creation + let session_id = store + .create_auth_session( + "mcp-client".to_string(), + Some("profile email".to_string()), + Some("test-state".to_string()), + "test-session-123".to_string(), + ) + .await; + + assert_eq!(session_id, "test-session-123"); + + // Test token update and MCP token creation + let auth_token = AuthToken::new( + AccessToken::new("mock-third-party-token".to_string()), + oauth2::basic::BasicTokenType::Bearer, + EmptyExtraTokenFields {}, + ); + + let update_result = store + .update_auth_session_token(&session_id, auth_token) + .await; + assert!(update_result.is_ok()); + + let mcp_token = store.create_mcp_token(&session_id).await; + assert!(mcp_token.is_ok()); + + let token = mcp_token.unwrap(); + assert!(token.access_token.starts_with("mcp-token-")); + assert_eq!(token.client_id, "mcp-client"); + + // Test token validation + let validated = store.validate_token(&token.access_token).await; + assert!(validated.is_some()); + } + + #[tokio::test] + async fn test_oauth_authorization_flow_mock() { + use common::oauth::{AuthorizeQuery, McpOAuthStore}; + use std::sync::Arc; + + let store = Arc::new(McpOAuthStore::new()); + + // Mock authorization request + let auth_query = AuthorizeQuery { + response_type: "code".to_string(), + client_id: "mcp-client".to_string(), + redirect_uri: "http://localhost:8080/callback".to_string(), + scope: Some("profile email".to_string()), + state: Some("test-state-456".to_string()), + }; + + // Test that oauth_authorize function can be called + // Note: In a real test, we'd use test frameworks like tower::ServiceExt + // but here we're testing the basic functionality + let store_clone = store.clone(); + let sessions_before = store_clone.auth_sessions.read().await.len(); + + // Verify store is accessible and functional + assert_eq!(sessions_before, 0); + + // Test client validation within the flow + let client_validation = store + .validate_client(&auth_query.client_id, &auth_query.redirect_uri) + .await; + assert!(client_validation.is_some()); + } + + #[tokio::test] + async fn test_oauth_token_exchange_mock() { + use common::oauth::AuthToken; + use common::oauth::{McpOAuthStore, TokenRequest}; + use oauth2::{AccessToken, EmptyExtraTokenFields}; + use std::sync::Arc; + + let store = Arc::new(McpOAuthStore::new()); + + // Create a session and add auth token (simulating successful OAuth flow) + let session_id = store + .create_auth_session( + "mcp-client".to_string(), + Some("profile".to_string()), + Some("test-state".to_string()), + "token-exchange-session".to_string(), + ) + .await; + + let auth_token = AuthToken::new( + AccessToken::new("mock-external-token".to_string()), + oauth2::basic::BasicTokenType::Bearer, + EmptyExtraTokenFields {}, + ); + + store + .update_auth_session_token(&session_id, auth_token) + .await + .unwrap(); + + // Mock token request (just for structure validation) + let _token_request = TokenRequest { + grant_type: "authorization_code".to_string(), + code: "mock-auth-code".to_string(), + client_id: "mcp-client".to_string(), + client_secret: "mcp-client-secret".to_string(), + redirect_uri: "http://localhost:8080/callback".to_string(), + code_verifier: None, + refresh_token: "".to_string(), + }; + + // Test token creation + let mcp_token_result = store.create_mcp_token(&session_id).await; + assert!(mcp_token_result.is_ok()); + + let mcp_token = mcp_token_result.unwrap(); + assert_eq!(mcp_token.token_type, "bearer"); + assert_eq!(mcp_token.expires_in, Some(3600)); + assert!(mcp_token.refresh_token.is_some()); + + // Verify token can be validated + let validation_result = store.validate_token(&mcp_token.access_token).await; + assert!(validation_result.is_some()); + } + + #[tokio::test] + async fn test_oauth_middleware_functionality() { + use common::oauth::AuthToken; + use common::oauth::McpOAuthStore; + use oauth2::{AccessToken, EmptyExtraTokenFields}; + use std::sync::Arc; + + let store = Arc::new(McpOAuthStore::new()); + + // Create a valid token for middleware testing + let session_id = store + .create_auth_session( + "mcp-client".to_string(), + Some("profile".to_string()), + None, + "middleware-test-session".to_string(), + ) + .await; + + let auth_token = AuthToken::new( + AccessToken::new("middleware-test-token".to_string()), + oauth2::basic::BasicTokenType::Bearer, + EmptyExtraTokenFields {}, + ); + + store + .update_auth_session_token(&session_id, auth_token) + .await + .unwrap(); + let mcp_token = store.create_mcp_token(&session_id).await.unwrap(); + + // Test token validation (simulating middleware behavior) + let valid_token_check = store.validate_token(&mcp_token.access_token).await; + assert!(valid_token_check.is_some()); + + // Test invalid token + let invalid_token_check = store.validate_token("invalid-token-12345").await; + assert!(invalid_token_check.is_none()); + + // Test empty token + let empty_token_check = store.validate_token("").await; + assert!(empty_token_check.is_none()); + } + + #[tokio::test] + async fn test_oauth_error_handling() { + use common::oauth::McpOAuthStore; + use std::sync::Arc; + + let store = Arc::new(McpOAuthStore::new()); + + // Test creating MCP token without session + let no_session_result = store.create_mcp_token("nonexistent-session").await; + assert!(no_session_result.is_err()); + assert_eq!(no_session_result.unwrap_err(), "Session not found"); + + // Test creating MCP token without auth token in session + let session_id = store + .create_auth_session( + "mcp-client".to_string(), + Some("profile".to_string()), + None, + "no-auth-token-session".to_string(), + ) + .await; + + let no_auth_token_result = store.create_mcp_token(&session_id).await; + assert!(no_auth_token_result.is_err()); + assert_eq!( + no_auth_token_result.unwrap_err(), + "No third-party token available for session" + ); + + // Test updating nonexistent session + let auth_token = oauth2::StandardTokenResponse::new( + oauth2::AccessToken::new("test-token".to_string()), + oauth2::basic::BasicTokenType::Bearer, + oauth2::EmptyExtraTokenFields {}, + ); + + let update_nonexistent = store + .update_auth_session_token("nonexistent", auth_token) + .await; + assert!(update_nonexistent.is_err()); + assert_eq!(update_nonexistent.unwrap_err(), "Session not found"); + } + + #[tokio::test] + async fn test_oauth_security_validations() { + use common::oauth::McpOAuthStore; + use std::sync::Arc; + + let store = Arc::new(McpOAuthStore::new()); + + // Test invalid client ID + let invalid_client = store + .validate_client("malicious-client", "http://localhost:8080/callback") + .await; + assert!(invalid_client.is_none()); + + // Test invalid redirect URI (potential open redirect attack) + let malicious_redirect = store + .validate_client("mcp-client", "http://evil.com/steal-tokens") + .await; + assert!(malicious_redirect.is_none()); + + // Test valid client with valid redirect URI + let valid_client = store + .validate_client("mcp-client", "http://localhost:8080/callback") + .await; + assert!(valid_client.is_some()); + + // Test that tokens are properly random and unique + let session1_id = store + .create_auth_session( + "mcp-client".to_string(), + Some("profile".to_string()), + None, + "security-test-1".to_string(), + ) + .await; + + let session2_id = store + .create_auth_session( + "mcp-client".to_string(), + Some("profile".to_string()), + None, + "security-test-2".to_string(), + ) + .await; + + // Add auth tokens to both sessions + for (i, session_id) in [&session1_id, &session2_id].iter().enumerate() { + let auth_token = oauth2::StandardTokenResponse::new( + oauth2::AccessToken::new(format!("security-token-{i}")), + oauth2::basic::BasicTokenType::Bearer, + oauth2::EmptyExtraTokenFields {}, + ); + store + .update_auth_session_token(session_id, auth_token) + .await + .unwrap(); + } + + let token1 = store.create_mcp_token(&session1_id).await.unwrap(); + let token2 = store.create_mcp_token(&session2_id).await.unwrap(); + + // Tokens should be different + assert_ne!(token1.access_token, token2.access_token); + assert_ne!(token1.refresh_token, token2.refresh_token); + + // Both should be valid + assert!(store.validate_token(&token1.access_token).await.is_some()); + assert!(store.validate_token(&token2.access_token).await.is_some()); + } +} diff --git a/mcp-servers/mcp-bash-server/templates/mcp_oauth_authorize.html b/mcp-servers/mcp-bash-server/templates/mcp_oauth_authorize.html new file mode 100644 index 00000000000..c9a15936fb3 --- /dev/null +++ b/mcp-servers/mcp-bash-server/templates/mcp_oauth_authorize.html @@ -0,0 +1,125 @@ + + + + + + + + MCP OAuth + + + +
+

MCP OAuth

+
+

{{ client_id }} requests access to your account.

+

requested scopes: {{ scopes }}

+
+ +
+ + + + + +
+ + +
+
+
+ + \ No newline at end of file