diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..bfd2fbd --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,134 @@ +# Labeler configuration for mcp-execution project +# Documentation: https://github.com/actions/labeler + +# Crate-specific labels +'crate: mcp-core': + - changed-files: + - any-glob-to-any-file: + - 'crates/mcp-core/**/*' + - 'crates/mcp-core/*' + +'crate: mcp-introspector': + - changed-files: + - any-glob-to-any-file: + - 'crates/mcp-introspector/**/*' + - 'crates/mcp-introspector/*' + +'crate: mcp-codegen': + - changed-files: + - any-glob-to-any-file: + - 'crates/mcp-codegen/**/*' + - 'crates/mcp-codegen/*' + +'crate: mcp-files': + - changed-files: + - any-glob-to-any-file: + - 'crates/mcp-files/**/*' + - 'crates/mcp-files/*' + +'crate: mcp-cli': + - changed-files: + - any-glob-to-any-file: + - 'crates/mcp-cli/**/*' + - 'crates/mcp-cli/*' + +# Type labels +'type: documentation': + - changed-files: + - any-glob-to-any-file: + - 'docs/**/*' + - '**/*.md' + - 'LICENSE' + - 'CHANGELOG.md' + - 'CLAUDE.md' + +'type: ci': + - changed-files: + - any-glob-to-any-file: + - '.github/**/*' + - '.github/workflows/**/*' + - 'deny.toml' + - 'codecov.yml' + - 'rust-toolchain.toml' + +'type: dependencies': + - changed-files: + - any-glob-to-any-file: + - '**/Cargo.toml' + - 'Cargo.lock' + - '.cargo/**/*' + +'type: tests': + - changed-files: + - any-glob-to-any-file: + - 'tests/**/*' + - '**/*_test.rs' + - '**/tests.rs' + - '**/test_*.rs' + +'type: benchmarks': + - changed-files: + - any-glob-to-any-file: + - 'benches/**/*' + - '**/*_bench.rs' + - '**/bench_*.rs' + +'type: examples': + - changed-files: + - any-glob-to-any-file: + - 'examples/**/*' + +# Architecture Decision Records +'adr': + - changed-files: + - any-glob-to-any-file: + - 'docs/adr/**/*' + +# Build configuration +'build': + - changed-files: + - any-glob-to-any-file: + - 'Cargo.toml' + - 'build.rs' + - '**/build.rs' + - 'rust-toolchain.toml' + +# Security +'security': + - changed-files: + - any-glob-to-any-file: + - 'deny.toml' + - 'SECURITY.md' + +# Multiple crates (workspace-wide changes) +'workspace': + - changed-files: + - all-globs-to-all-files: + - 'crates/*/Cargo.toml' + - any-glob-to-any-file: + - 'Cargo.toml' + - 'Cargo.lock' + +# Breaking changes detection (based on file patterns) +'breaking change': + - changed-files: + - any-glob-to-any-file: + - 'crates/*/src/lib.rs' + - 'crates/*/src/types.rs' + - 'crates/*/src/error.rs' + - 'CHANGELOG.md' + - head-branch: + - '^breaking/.*' + - '^major/.*' + +# Release preparation +'release': + - changed-files: + - any-glob-to-any-file: + - 'CHANGELOG.md' + - 'Cargo.toml' + - all-globs-to-all-files: + - 'crates/*/Cargo.toml' + - head-branch: + - '^release/.*' + - '^v[0-9]+\.[0-9]+\.[0-9]+.*' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 623c423..ced547b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,16 +104,6 @@ jobs: with: toolchain: ${{ matrix.rust }} - # Setup sccache for faster compilation - - name: Setup sccache - uses: mozilla-actions/sccache-action@v0.0.9 - - - name: Configure sccache - shell: bash - run: | - echo "RUSTC_WRAPPER=sccache" >> $GITHUB_ENV - echo "SCCACHE_GHA_ENABLED=true" >> $GITHUB_ENV - - name: Cache Cargo uses: Swatinem/rust-cache@v2 with: @@ -134,9 +124,6 @@ jobs: - name: Run doctests run: cargo test --doc --all-features --workspace - - name: sccache stats - run: sccache --show-stats - # Code coverage (Linux only for speed) coverage: name: Code Coverage @@ -228,14 +215,6 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@stable - - name: Setup sccache - uses: mozilla-actions/sccache-action@v0.0.9 - - - name: Configure sccache - run: | - echo "RUSTC_WRAPPER=sccache" >> $GITHUB_ENV - echo "SCCACHE_GHA_ENABLED=true" >> $GITHUB_ENV - - name: Cache Cargo uses: Swatinem/rust-cache@v2 with: @@ -244,9 +223,6 @@ jobs: - name: Build benchmarks (no run) run: cargo bench --no-run --profile bench-fast --workspace - - name: sccache stats - run: sccache --show-stats - # Release build check release: name: Release Build @@ -261,14 +237,6 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@stable - - name: Setup sccache - uses: mozilla-actions/sccache-action@v0.0.9 - - - name: Configure sccache - run: | - echo "RUSTC_WRAPPER=sccache" >> $GITHUB_ENV - echo "SCCACHE_GHA_ENABLED=true" >> $GITHUB_ENV - - name: Cache Cargo uses: Swatinem/rust-cache@v2 with: @@ -285,9 +253,6 @@ jobs: echo "Target directory size:" du -sh target/release/ - - name: sccache stats - run: sccache --show-stats - # All checks passed ci-success: name: CI Success diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 0000000..ba5823a --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,23 @@ +name: PR Labeler + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +jobs: + label: + name: Label Pull Request + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Apply labels + uses: actions/labeler@v5 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + configuration-path: .github/labeler.yml + sync-labels: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a16561c..625c179 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,15 +60,6 @@ jobs: sudo apt-get update sudo apt-get install -y gcc-aarch64-linux-gnu - - name: Setup sccache - uses: mozilla-actions/sccache-action@v0.0.9 - - - name: Configure sccache - shell: bash - run: | - echo "RUSTC_WRAPPER=sccache" >> $GITHUB_ENV - echo "SCCACHE_GHA_ENABLED=true" >> $GITHUB_ENV - - name: Cache Cargo uses: Swatinem/rust-cache@v2 with: diff --git a/Cargo.lock b/Cargo.lock index 599fee3..a76b3ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -170,9 +179,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.47" +version = "1.2.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd405d82c84ff7f35739f175f67d8b9fb7687a0e84ccdc78bd3568839827cf07" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" dependencies = [ "find-msvc-tools", "shlex", @@ -325,10 +334,11 @@ dependencies = [ [[package]] name = "criterion" -version = "0.7.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1c047a62b0cc3e145fa84415a3191f628e980b194c2755aa12300a4e6cbd928" +checksum = "4d883447757bb0ee46f233e9dc22eb84d93a9508c9b868687b274fc431d886bf" dependencies = [ + "alloca", "anes", "cast", "ciborium", @@ -337,6 +347,7 @@ dependencies = [ "itertools", "num-traits", "oorandom", + "page_size", "plotters", "rayon", "regex", @@ -349,9 +360,9 @@ dependencies = [ [[package]] name = "criterion-plot" -version = "0.6.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b1bcc0dc7dfae599d84ad0b1a55f80cde8af3725da8313b528da95ef783e338" +checksum = "ed943f81ea2faa8dcecbbfa50164acf95d555afec96a27871663b300e387b2e4" dependencies = [ "cast", "itertools", @@ -846,9 +857,9 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "js-sys" -version = "0.3.82" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" dependencies = [ "once_cell", "wasm-bindgen", @@ -862,9 +873,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.177" +version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" [[package]] name = "libredox" @@ -893,9 +904,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "matchers" @@ -944,6 +955,8 @@ dependencies = [ "clap", "clap_complete", "colored", + "criterion", + "dhat", "dialoguer", "dirs", "mcp-codegen", @@ -986,6 +999,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "mcp-server" +version = "0.5.0" +dependencies = [ + "anyhow", + "chrono", + "dirs", + "mcp-codegen", + "mcp-core", + "mcp-files", + "mcp-introspector", + "rmcp", + "schemars", + "serde", + "serde_json", + "tempfile", + "thiserror", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + [[package]] name = "memchr" version = "2.7.6" @@ -1009,9 +1045,9 @@ checksum = "c505b3e17ed6b70a7ed2e67fbb2c560ee327353556120d6e72f5232b6880d536" [[package]] name = "mio" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "wasi", @@ -1096,6 +1132,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1120,10 +1166,10 @@ dependencies = [ ] [[package]] -name = "paste" -version = "1.0.15" +name = "pastey" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +checksum = "57d6c094ee800037dff99e02cab0eaf3142826586742a270ab3d7a62656bd27a" [[package]] name = "pest" @@ -1375,15 +1421,15 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "rmcp" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaa07b85b779d1e1df52dd79f6c6bffbe005b191f07290136cc42a142da3409a" +checksum = "38b18323edc657390a6ed4d7a9110b0dec2dc3ed128eb2a123edfbafabdbddc5" dependencies = [ "async-trait", "base64", "chrono", "futures", - "paste", + "pastey", "pin-project-lite", "process-wrap", "rmcp-macros", @@ -1399,9 +1445,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f6fa09933cac0d0204c8a5d647f558425538ed6a0134b1ebb1ae4dc00c96db3" +checksum = "c75d0a62676bf8c8003c4e3c348e2ceb6a7b3e48323681aaf177fdccdac2ce50" dependencies = [ "darling 0.21.3", "proc-macro2", @@ -1468,6 +1514,7 @@ dependencies = [ "schemars_derive", "serde", "serde_json", + "uuid", ] [[package]] @@ -1770,9 +1817,9 @@ checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -1781,9 +1828,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -1792,9 +1839,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" dependencies = [ "once_cell", "valuable", @@ -1823,9 +1870,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.20" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "matchers", "nu-ansi-term", @@ -1874,13 +1921,14 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.18.1" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" dependencies = [ "getrandom 0.3.4", "js-sys", "rand", + "serde_core", "wasm-bindgen", ] @@ -1923,9 +1971,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" dependencies = [ "cfg-if", "once_cell", @@ -1936,9 +1984,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1946,9 +1994,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" dependencies = [ "bumpalo", "proc-macro2", @@ -1959,18 +2007,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.82" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" dependencies = [ "js-sys", "wasm-bindgen", @@ -1987,6 +2035,22 @@ dependencies = [ "winsafe", ] +[[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-util" version = "0.1.11" @@ -1996,6 +2060,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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" version = "0.61.3" @@ -2228,9 +2298,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.7.13" +version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" [[package]] name = "winsafe" @@ -2246,18 +2316,18 @@ checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "zerocopy" -version = "0.8.30" +version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea879c944afe8a2b25fef16bb4ba234f47c694565e97383b36f3a878219065c" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.30" +version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf955aa904d6040f70dc8e9384444cb1030aed272ba3cb09bbc4ab9e7c1f34f5" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 9b20f6b..781ab18 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,17 +19,19 @@ chrono = "0.4" clap = "4.5" clap_complete = "4.5" colored = "3.0" -criterion = "0.7" +criterion = "0.8" dhat = "0.3" dialoguer = "0.12" dirs = "6.0" handlebars = "6.3" mcp-codegen = { path = "crates/mcp-codegen" } mcp-core = { path = "crates/mcp-core" } -mcp-introspector = { path = "crates/mcp-introspector" } mcp-files = { path = "crates/mcp-files" } +mcp-introspector = { path = "crates/mcp-introspector" } +mcp-server = { path = "crates/mcp-server" } rayon = "1.11" -rmcp = "0.9" +rmcp = "0.10" +schemars = "1.1.0" serde = "1.0" serde_json = "1.0" static_assertions = "1.1" @@ -39,7 +41,7 @@ tokio = "1.48" toml = "0.9" tracing = "0.1" tracing-subscriber = "0.3" -uuid = "1.11" +uuid = { version = "1.19", features = ["v4", "serde"] } which = "8.0" [workspace.lints.rust] diff --git a/crates/mcp-cli/Cargo.toml b/crates/mcp-cli/Cargo.toml index ac236e1..141b78c 100644 --- a/crates/mcp-cli/Cargo.toml +++ b/crates/mcp-cli/Cargo.toml @@ -20,23 +20,25 @@ name = "mcp-execution-cli" path = "src/main.rs" [dependencies] -anyhow.workspace = true +anyhow = { workspace = true } clap = { workspace = true, features = ["derive", "env", "cargo", "color"] } clap_complete.workspace = true -colored.workspace = true -dialoguer.workspace = true -dirs.workspace = true -mcp-codegen.workspace = true -mcp-core.workspace = true -mcp-introspector.workspace = true -mcp-files.workspace = true +colored = { workspace = true } +criterion = { workspace = true } +dhat = { workspace = true } +dialoguer = { workspace = true } +dirs = { workspace = true } +mcp-codegen = { workspace = true } +mcp-core = { workspace = true } +mcp-introspector = { workspace = true } +mcp-files = { workspace = true } serde = { workspace = true, features = ["derive"] } -serde_json.workspace = true +serde_json = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "fs"] } -toml.workspace = true -tracing.workspace = true +toml = { workspace = true } +tracing = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter", "json"] } -which.workspace = true +which = { workspace = true } [dev-dependencies] -tempfile.workspace = true +tempfile = { workspace = true } diff --git a/crates/mcp-codegen/Cargo.toml b/crates/mcp-codegen/Cargo.toml index e566c2f..4e137e8 100644 --- a/crates/mcp-codegen/Cargo.toml +++ b/crates/mcp-codegen/Cargo.toml @@ -13,16 +13,16 @@ workspace = true default = [] [dependencies] -handlebars.workspace = true -mcp-core.workspace = true -mcp-introspector.workspace = true +handlebars = { workspace = true } +mcp-core = { workspace = true } +mcp-introspector = { workspace = true } serde = { workspace = true, features = ["derive"] } -serde_json.workspace = true -tracing.workspace = true +serde_json = { workspace = true } +tracing = { workspace = true } [dev-dependencies] -criterion.workspace = true -mcp-files.workspace = true +criterion = { workspace = true } +mcp-files = { workspace = true } [[bench]] name = "code_generation" diff --git a/crates/mcp-codegen/src/progressive/generator.rs b/crates/mcp-codegen/src/progressive/generator.rs index 68658bc..22f445a 100644 --- a/crates/mcp-codegen/src/progressive/generator.rs +++ b/crates/mcp-codegen/src/progressive/generator.rs @@ -33,11 +33,13 @@ use crate::common::types::{GeneratedCode, GeneratedFile}; use crate::common::typescript::{extract_properties, to_camel_case}; use crate::progressive::types::{ - BridgeContext, IndexContext, PropertyInfo, ToolContext, ToolSummary, + BridgeContext, CategoryInfo, IndexContext, PropertyInfo, ToolCategorization, ToolContext, + ToolSummary, }; use crate::template_engine::TemplateEngine; use mcp_core::{Error, Result}; use mcp_introspector::ServerInfo; +use std::collections::HashMap; /// Generator for progressive loading TypeScript files. /// @@ -146,7 +148,7 @@ impl<'a> ProgressiveGenerator<'a> { // Generate tool files (one per tool) for tool in &server_info.tools { - let tool_context = self.create_tool_context(server_id, tool)?; + let tool_context = self.create_tool_context(server_id, tool, None)?; let tool_code = self.engine.render("progressive/tool", &tool_context)?; code.add_file(GeneratedFile { @@ -158,7 +160,7 @@ impl<'a> ProgressiveGenerator<'a> { } // Generate index.ts - let index_context = self.create_index_context(server_info)?; + let index_context = self.create_index_context(server_info, None)?; let index_code = self.engine.render("progressive/index", &index_context)?; code.add_file(GeneratedFile { @@ -190,6 +192,127 @@ impl<'a> ProgressiveGenerator<'a> { Ok(code) } + /// Generates progressive loading files with categorization metadata. + /// + /// Like `generate`, but includes full categorization information from Claude's + /// analysis. Categories, keywords, and short descriptions are displayed in + /// the index file and included in individual tool file headers. + /// + /// # Arguments + /// + /// * `server_info` - MCP server introspection data + /// * `categorizations` - Map of tool name to categorization metadata + /// + /// # Returns + /// + /// Generated code with categorization metadata included. + /// + /// # Errors + /// + /// Returns error if template rendering fails. + /// + /// # Examples + /// + /// ```no_run + /// use mcp_codegen::progressive::{ProgressiveGenerator, ToolCategorization}; + /// use mcp_introspector::{ServerInfo, ServerCapabilities}; + /// use mcp_core::ServerId; + /// use std::collections::HashMap; + /// + /// # fn example() -> Result<(), Box> { + /// let generator = ProgressiveGenerator::new()?; + /// + /// let info = ServerInfo { + /// id: ServerId::new("github"), + /// name: "GitHub".to_string(), + /// version: "1.0.0".to_string(), + /// tools: vec![], + /// capabilities: ServerCapabilities { + /// supports_tools: true, + /// supports_resources: false, + /// supports_prompts: false, + /// }, + /// }; + /// + /// let mut categorizations = HashMap::new(); + /// categorizations.insert("create_issue".to_string(), ToolCategorization { + /// category: "issues".to_string(), + /// keywords: "create,issue,new,bug".to_string(), + /// short_description: "Create a new issue".to_string(), + /// }); + /// + /// let code = generator.generate_with_categories(&info, &categorizations)?; + /// # Ok(()) + /// # } + /// ``` + pub fn generate_with_categories( + &self, + server_info: &ServerInfo, + categorizations: &HashMap, + ) -> Result { + tracing::info!( + "Generating progressive loading code with categorizations for server: {}", + server_info.name + ); + + let mut code = GeneratedCode::new(); + let server_id = server_info.id.as_str(); + + // Generate tool files (one per tool) with categorization metadata + for tool in &server_info.tools { + let tool_name = tool.name.as_str(); + let categorization = categorizations.get(tool_name); + let tool_context = self.create_tool_context(server_id, tool, categorization)?; + let tool_code = self.engine.render("progressive/tool", &tool_context)?; + + code.add_file(GeneratedFile { + path: format!("{}.ts", tool_context.typescript_name), + content: tool_code, + }); + + tracing::debug!( + "Generated tool file: {}.ts (category: {:?})", + tool_context.typescript_name, + categorization.map(|c| &c.category) + ); + } + + // Generate index.ts with category grouping + let index_context = self.create_index_context(server_info, Some(categorizations))?; + let index_code = self.engine.render("progressive/index", &index_context)?; + + code.add_file(GeneratedFile { + path: "index.ts".to_string(), + content: index_code, + }); + + tracing::debug!( + "Generated index.ts with {} categorizations", + categorizations.len() + ); + + // Generate runtime bridge (same as non-categorized) + let bridge_context = BridgeContext::default(); + let bridge_code = self + .engine + .render("progressive/runtime-bridge", &bridge_context)?; + + code.add_file(GeneratedFile { + path: "_runtime/mcp-bridge.ts".to_string(), + content: bridge_code, + }); + + tracing::debug!("Generated _runtime/mcp-bridge.ts"); + + tracing::info!( + "Successfully generated {} files for {} with categorizations (progressive loading)", + code.file_count(), + server_info.name + ); + + Ok(code) + } + /// Creates tool context from MCP tool information. /// /// Converts MCP tool schema to the format needed for template rendering. @@ -201,6 +324,7 @@ impl<'a> ProgressiveGenerator<'a> { &self, server_id: &str, tool: &mcp_introspector::ToolInfo, + categorization: Option<&ToolCategorization>, ) -> Result { let typescript_name = to_camel_case(tool.name.as_str()); @@ -214,25 +338,71 @@ impl<'a> ProgressiveGenerator<'a> { description: tool.description.clone(), input_schema: tool.input_schema.clone(), properties, + category: categorization.map(|c| c.category.clone()), + keywords: categorization.map(|c| c.keywords.clone()), + short_description: categorization.map(|c| c.short_description.clone()), }) } /// Creates index context from server information. - fn create_index_context(&self, server_info: &ServerInfo) -> Result { + fn create_index_context( + &self, + server_info: &ServerInfo, + categorizations: Option<&HashMap>, + ) -> Result { let tools: Vec = server_info .tools .iter() - .map(|tool| ToolSummary { - typescript_name: to_camel_case(tool.name.as_str()), - description: tool.description.clone(), + .map(|tool| { + let tool_name = tool.name.as_str(); + let cat = categorizations.and_then(|c| c.get(tool_name)); + ToolSummary { + typescript_name: to_camel_case(tool_name), + description: tool.description.clone(), + category: cat.map(|c| c.category.clone()), + keywords: cat.map(|c| c.keywords.clone()), + short_description: cat.map(|c| c.short_description.clone()), + } }) .collect(); + // Build category groups if categorizations are provided + let category_groups = categorizations.map(|_| { + let mut groups: HashMap> = HashMap::new(); + + for tool in &tools { + let cat_name = tool + .category + .clone() + .unwrap_or_else(|| "uncategorized".to_string()); + groups.entry(cat_name).or_default().push(tool.clone()); + } + + let mut result: Vec = groups + .into_iter() + .map(|(name, tools)| CategoryInfo { name, tools }) + .collect(); + + // Sort categories alphabetically, but keep "uncategorized" last + result.sort_by(|a, b| { + if a.name == "uncategorized" { + std::cmp::Ordering::Greater + } else if b.name == "uncategorized" { + std::cmp::Ordering::Less + } else { + a.name.cmp(&b.name) + } + }); + + result + }); + Ok(IndexContext { server_name: server_info.name.clone(), server_version: server_info.version.clone(), tool_count: server_info.tools.len(), tools, + categories: category_groups, }) } @@ -391,7 +561,14 @@ mod tests { output_schema: None, }; - let context = generator.create_tool_context("test-server", &tool).unwrap(); + let categorization = ToolCategorization { + category: "messaging".to_string(), + keywords: "send,message,chat".to_string(), + short_description: "Send a message".to_string(), + }; + let context = generator + .create_tool_context("test-server", &tool, Some(&categorization)) + .unwrap(); assert_eq!(context.server_id, "test-server"); assert_eq!(context.name, "send_message"); @@ -399,6 +576,12 @@ mod tests { assert_eq!(context.description, "Sends a message"); assert_eq!(context.properties.len(), 1); assert_eq!(context.properties[0].name, "text"); + assert_eq!(context.category, Some("messaging".to_string())); + assert_eq!(context.keywords, Some("send,message,chat".to_string())); + assert_eq!( + context.short_description, + Some("Send a message".to_string()) + ); } #[test] @@ -406,13 +589,14 @@ mod tests { let generator = ProgressiveGenerator::new().unwrap(); let server_info = create_test_server_info(); - let context = generator.create_index_context(&server_info).unwrap(); + let context = generator.create_index_context(&server_info, None).unwrap(); assert_eq!(context.server_name, "Test Server"); assert_eq!(context.server_version, "1.0.0"); assert_eq!(context.tool_count, 2); assert_eq!(context.tools.len(), 2); assert_eq!(context.tools[0].typescript_name, "createIssue"); + assert!(context.categories.is_none()); } #[test] diff --git a/crates/mcp-codegen/src/progressive/mod.rs b/crates/mcp-codegen/src/progressive/mod.rs index bf2f8c3..b551146 100644 --- a/crates/mcp-codegen/src/progressive/mod.rs +++ b/crates/mcp-codegen/src/progressive/mod.rs @@ -96,4 +96,7 @@ pub mod types; // Re-export main types pub use generator::ProgressiveGenerator; -pub use types::{BridgeContext, IndexContext, PropertyInfo, ToolContext, ToolSummary}; +pub use types::{ + BridgeContext, CategoryInfo, IndexContext, PropertyInfo, ToolCategorization, ToolContext, + ToolSummary, +}; diff --git a/crates/mcp-codegen/src/progressive/types.rs b/crates/mcp-codegen/src/progressive/types.rs index 413fbdd..b158130 100644 --- a/crates/mcp-codegen/src/progressive/types.rs +++ b/crates/mcp-codegen/src/progressive/types.rs @@ -23,6 +23,9 @@ use serde::{Deserialize, Serialize}; /// description: "Creates a new issue".to_string(), /// input_schema: json!({"type": "object"}), /// properties: vec![], +/// category: Some("issues".to_string()), +/// keywords: Some("create,issue,new,bug".to_string()), +/// short_description: Some("Create a new issue".to_string()), /// }; /// /// assert_eq!(context.server_id, "github"); @@ -41,6 +44,12 @@ pub struct ToolContext { pub input_schema: serde_json::Value, /// Extracted properties for template rendering pub properties: Vec, + /// Optional category for tool grouping + pub category: Option, + /// Optional keywords for discovery via grep/search + pub keywords: Option, + /// Optional short description for header comment + pub short_description: Option, } /// Information about a single parameter property. @@ -88,6 +97,7 @@ pub struct PropertyInfo { /// server_version: "1.0.0".to_string(), /// tool_count: 30, /// tools: vec![], +/// categories: None, /// }; /// /// assert_eq!(context.tool_count, 30); @@ -102,6 +112,9 @@ pub struct IndexContext { pub tool_count: usize, /// List of tool summaries pub tools: Vec, + /// Tools grouped by category (optional, for categorized generation) + #[serde(skip_serializing_if = "Option::is_none")] + pub categories: Option>, } /// Summary of a tool for index file generation. @@ -117,6 +130,9 @@ pub struct IndexContext { /// let summary = ToolSummary { /// typescript_name: "createIssue".to_string(), /// description: "Creates a new issue".to_string(), +/// category: Some("issues".to_string()), +/// keywords: Some("create,issue,new".to_string()), +/// short_description: Some("Create a new issue".to_string()), /// }; /// /// assert_eq!(summary.typescript_name, "createIssue"); @@ -127,6 +143,71 @@ pub struct ToolSummary { pub typescript_name: String, /// Human-readable description pub description: String, + /// Optional category for tool grouping + pub category: Option, + /// Optional keywords for discovery via grep/search + pub keywords: Option, + /// Optional short description for header comment + pub short_description: Option, +} + +/// Categorization metadata for a single tool. +/// +/// Contains all categorization data from Claude's analysis. +/// +/// # Examples +/// +/// ``` +/// use mcp_codegen::progressive::ToolCategorization; +/// +/// let cat = ToolCategorization { +/// category: "issues".to_string(), +/// keywords: "create,issue,new,bug".to_string(), +/// short_description: "Create a new issue in a repository".to_string(), +/// }; +/// +/// assert_eq!(cat.category, "issues"); +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCategorization { + /// Category for tool grouping + pub category: String, + /// Comma-separated keywords for discovery + pub keywords: String, + /// Concise description for header comment + pub short_description: String, +} + +/// Category information for grouped tool display in index. +/// +/// Groups tools by category for organized documentation. +/// +/// # Examples +/// +/// ``` +/// use mcp_codegen::progressive::{CategoryInfo, ToolSummary}; +/// +/// let category = CategoryInfo { +/// name: "issues".to_string(), +/// tools: vec![ +/// ToolSummary { +/// typescript_name: "createIssue".to_string(), +/// description: "Creates a new issue".to_string(), +/// category: Some("issues".to_string()), +/// keywords: Some("create,issue".to_string()), +/// short_description: Some("Create issue".to_string()), +/// }, +/// ], +/// }; +/// +/// assert_eq!(category.name, "issues"); +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CategoryInfo { + /// Category name + pub name: String, + /// Tools in this category + pub tools: Vec, } /// Context for rendering the runtime bridge template. @@ -161,11 +242,16 @@ mod tests { description: "Creates an issue".to_string(), input_schema: json!({"type": "object"}), properties: vec![], + category: Some("issues".to_string()), + keywords: Some("create,issue,new".to_string()), + short_description: Some("Create a new issue".to_string()), }; assert_eq!(context.server_id, "github"); assert_eq!(context.name, "create_issue"); assert_eq!(context.typescript_name, "createIssue"); + assert_eq!(context.category, Some("issues".to_string())); + assert_eq!(context.keywords, Some("create,issue,new".to_string())); } #[test] @@ -189,10 +275,12 @@ mod tests { server_version: "1.0.0".to_string(), tool_count: 5, tools: vec![], + categories: None, }; assert_eq!(context.server_name, "GitHub"); assert_eq!(context.tool_count, 5); + assert!(context.categories.is_none()); } #[test] @@ -200,9 +288,14 @@ mod tests { let summary = ToolSummary { typescript_name: "createIssue".to_string(), description: "Creates an issue".to_string(), + category: Some("issues".to_string()), + keywords: Some("create,issue".to_string()), + short_description: Some("Create issue".to_string()), }; assert_eq!(summary.typescript_name, "createIssue"); + assert_eq!(summary.category, Some("issues".to_string())); + assert_eq!(summary.keywords, Some("create,issue".to_string())); } #[test] diff --git a/crates/mcp-codegen/templates/progressive/index.ts.hbs b/crates/mcp-codegen/templates/progressive/index.ts.hbs index 81d4942..8336660 100644 --- a/crates/mcp-codegen/templates/progressive/index.ts.hbs +++ b/crates/mcp-codegen/templates/progressive/index.ts.hbs @@ -7,9 +7,19 @@ * ## Available Tools * * This server provides {{tool_count}} tools: +{{#if categories}} +{{#each categories}} + * + * ### {{name}} {{#each tools}} - * - `{{typescript_name}}`: {{description}} + * - `{{typescript_name}}`: {{#if short_description}}{{short_description}}{{else}}{{description}}{{/if}}{{#if keywords}} [{{keywords}}]{{/if}} +{{/each}} {{/each}} +{{else}} +{{#each tools}} + * - `{{typescript_name}}`: {{#if short_description}}{{short_description}}{{else}}{{description}}{{/if}}{{#if keywords}} [{{keywords}}]{{/if}} +{{/each}} +{{/if}} * * ## Usage * @@ -25,9 +35,19 @@ */ // Re-export all tools +{{#if categories}} +{{#each categories}} +// --- {{name}} --- +{{#each tools}} +export { {{typescript_name}}, type {{typescript_name}}Params, type {{typescript_name}}Result } from './{{typescript_name}}.js'; +{{/each}} + +{{/each}} +{{else}} {{#each tools}} export { {{typescript_name}}, type {{typescript_name}}Params, type {{typescript_name}}Result } from './{{typescript_name}}.js'; {{/each}} +{{/if}} // Re-export runtime bridge export { callMCPTool } from './_runtime/mcp-bridge.js'; diff --git a/crates/mcp-codegen/templates/progressive/tool.ts.hbs b/crates/mcp-codegen/templates/progressive/tool.ts.hbs index 1674a45..af932df 100644 --- a/crates/mcp-codegen/templates/progressive/tool.ts.hbs +++ b/crates/mcp-codegen/templates/progressive/tool.ts.hbs @@ -1,11 +1,35 @@ #!/usr/bin/env node +/** + * @tool {{name}} + * @server {{server_id}} +{{#if category}} + * @category {{category}} +{{/if}} +{{#if keywords}} + * @keywords {{keywords}} +{{/if}} +{{#if short_description}} + * @description {{short_description}} +{{/if}} + */ import { callMCPTool } from './_runtime/mcp-bridge.ts'; /** +{{#if short_description}} + * {{short_description}} +{{else}} * {{description}} +{{/if}} {{#if input_schema.description}} * * {{input_schema.description}} +{{/if}} +{{#if category}} + * + * @category {{category}} +{{/if}} +{{#if keywords}} + * @keywords {{keywords}} {{/if}} * * @param params - Tool parameters diff --git a/crates/mcp-core/Cargo.toml b/crates/mcp-core/Cargo.toml index d07767a..86c6a62 100644 --- a/crates/mcp-core/Cargo.toml +++ b/crates/mcp-core/Cargo.toml @@ -10,15 +10,15 @@ publish.workspace = true workspace = true [dependencies] -async-trait.workspace = true +async-trait = { workspace = true } chrono = { workspace = true, features = ["serde"] } -dirs.workspace = true +dirs = { workspace = true } serde = { workspace = true, features = ["derive"] } -serde_json.workspace = true -thiserror.workspace = true -tracing.workspace = true +serde_json = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } uuid = { workspace = true, features = ["v4", "fast-rng"] } [dev-dependencies] -tempfile.workspace = true +tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/crates/mcp-files/src/filesystem.rs b/crates/mcp-files/src/filesystem.rs index 671fc34..0a0180a 100644 --- a/crates/mcp-files/src/filesystem.rs +++ b/crates/mcp-files/src/filesystem.rs @@ -532,10 +532,22 @@ impl FileSystem { /// Converts VFS path to disk path. /// /// Strips leading '/' and joins with base path. + /// + /// # Panics + /// + /// Panics if path contains `..` (path traversal attempt). + /// This is defense-in-depth since `FilePath::new()` also validates. fn vfs_to_disk_path(vfs_path: &str, base: &Path) -> PathBuf { // Strip leading '/' from VFS path let relative = vfs_path.strip_prefix('/').unwrap_or(vfs_path); + // Defense-in-depth: reject path traversal attempts + // Primary validation is in FilePath::new(), this is a safety net + assert!( + !relative.contains(".."), + "SECURITY: Path traversal attempt detected in VFS path: {vfs_path}" + ); + // Convert forward slashes to platform-specific separators let relative_path = if cfg!(target_os = "windows") { PathBuf::from(relative.replace('/', "\\")) diff --git a/crates/mcp-server/Cargo.toml b/crates/mcp-server/Cargo.toml new file mode 100644 index 0000000..68655c1 --- /dev/null +++ b/crates/mcp-server/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "mcp-server" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true +description = "MCP server for generating progressive loading TypeScript files" +keywords = ["mcp", "server", "codegen", "progressive-loading"] +categories = ["development-tools"] + +[[bin]] +name = "mcp-execution" +path = "src/main.rs" + +[dependencies] +anyhow = { workspace = true } +chrono = { workspace = true, features = ["serde"] } +dirs = { workspace = true } +mcp-codegen = { workspace = true } +mcp-core = { workspace = true } +mcp-files = { workspace = true } +mcp-introspector = { workspace = true } +rmcp = { workspace = true, features = ["server", "transport-io", "macros"] } +schemars = { workspace = true, features = ["uuid1", "chrono04"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = [ + "fs", + "macros", + "rt-multi-thread", + "sync", +] } +tracing = { workspace = true } +tracing-subscriber = { workspace = true, features = ["env-filter"] } +uuid = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } +tokio = { workspace = true, features = ["test-util"] } + +[lints] +workspace = true diff --git a/crates/mcp-server/src/lib.rs b/crates/mcp-server/src/lib.rs new file mode 100644 index 0000000..41cf410 --- /dev/null +++ b/crates/mcp-server/src/lib.rs @@ -0,0 +1,62 @@ +//! MCP server library for progressive loading generation. +//! +//! This crate provides an MCP server that helps generate progressive loading +//! TypeScript files for other MCP servers. It leverages Claude's natural +//! language understanding for tool categorization - no separate LLM API needed. +//! +//! # Architecture +//! +//! The server implements three main tools: +//! +//! 1. **`introspect_server`** - Connect to a target MCP server and discover its tools +//! 2. **`save_categorized_tools`** - Generate TypeScript files with Claude's categorization +//! 3. **`list_generated_servers`** - List all servers with generated files +//! +//! # Workflow +//! +//! 1. User asks Claude to generate progressive loading for an MCP server +//! 2. Claude calls `introspect_server` to discover tools +//! 3. Claude analyzes tool metadata and assigns categories, keywords, descriptions +//! 4. Claude calls `save_categorized_tools` with categorization +//! 5. Server generates TypeScript files with discovery headers +//! +//! # Examples +//! +//! ```no_run +//! use mcp_server::service::GeneratorService; +//! use rmcp::transport::stdio; +//! use rmcp::ServiceExt; +//! +//! # async fn example() -> anyhow::Result<()> { +//! // Create and run the service +//! let service = GeneratorService::new().serve(stdio()).await?; +//! service.waiting().await?; +//! # Ok(()) +//! # } +//! ``` +//! +//! # State Management +//! +//! The server maintains temporary session state between `introspect_server` and +//! `save_categorized_tools` calls. Sessions expire after 30 minutes and are +//! cleaned up lazily. +//! +//! # Key Benefits +//! +//! - **No LLM API**: Claude (the conversation LLM) does categorization +//! - **Human-in-the-loop**: User can review and adjust categories +//! - **Progressive loading**: 98% token savings (30,000 → 500-1,500 tokens) +//! - **Type-safe**: Full TypeScript types from MCP schemas +//! - **Discoverable**: grep-friendly headers for tool discovery + +pub mod service; +pub mod state; +pub mod types; + +pub use service::GeneratorService; +pub use state::StateManager; +pub use types::{ + CategorizedTool, GeneratedServerInfo, IntrospectServerParams, IntrospectServerResult, + ListGeneratedServersParams, ListGeneratedServersResult, PendingGeneration, + SaveCategorizedToolsParams, SaveCategorizedToolsResult, ToolGenerationError, ToolMetadata, +}; diff --git a/crates/mcp-server/src/main.rs b/crates/mcp-server/src/main.rs new file mode 100644 index 0000000..50ea4ae --- /dev/null +++ b/crates/mcp-server/src/main.rs @@ -0,0 +1,59 @@ +//! MCP server entry point for progressive loading generation. +//! +//! This binary provides an MCP server that helps generate progressive loading +//! TypeScript files for other MCP servers. Claude provides categorization +//! intelligence through natural language understanding. +//! +//! # Usage +//! +//! Run the server via stdio transport: +//! +//! ```bash +//! mcp-execution-server +//! ``` +//! +//! Or configure in `~/.config/claude/mcp.json`: +//! +//! ```json +//! { +//! "mcpServers": { +//! "mcp-execution": { +//! "command": "mcp-execution-server" +//! } +//! } +//! } +//! ``` + +use anyhow::Result; +use mcp_server::service::GeneratorService; +use rmcp::ServiceExt; +use rmcp::transport::stdio; +use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; + +#[tokio::main] +async fn main() -> Result<()> { + // Initialize logging to stderr (stdout is for MCP protocol) + tracing_subscriber::registry() + .with( + EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("info,mcp_server=debug")), + ) + .with( + tracing_subscriber::fmt::layer() + .with_writer(std::io::stderr) + .with_target(true), + ) + .init(); + + tracing::info!( + "Starting mcp-execution-server v{}", + env!("CARGO_PKG_VERSION") + ); + + // Create and run the service with stdio transport + let service = GeneratorService::new().serve(stdio()).await?; + service.waiting().await?; + + tracing::info!("Server shutdown complete"); + Ok(()) +} diff --git a/crates/mcp-server/src/service.rs b/crates/mcp-server/src/service.rs new file mode 100644 index 0000000..a036e1d --- /dev/null +++ b/crates/mcp-server/src/service.rs @@ -0,0 +1,886 @@ +//! MCP server implementation for progressive loading generation. +//! +//! The `GeneratorService` provides three main tools: +//! 1. `introspect_server` - Connect to and introspect an MCP server +//! 2. `save_categorized_tools` - Generate TypeScript files with categorization +//! 3. `list_generated_servers` - List all servers with generated files + +use crate::state::StateManager; +use crate::types::{ + CategorizedTool, GeneratedServerInfo, IntrospectServerParams, IntrospectServerResult, + ListGeneratedServersParams, ListGeneratedServersResult, PendingGeneration, + SaveCategorizedToolsParams, SaveCategorizedToolsResult, ToolMetadata, +}; +use mcp_codegen::progressive::ProgressiveGenerator; +use mcp_core::{ServerConfig, ServerId}; +use mcp_files::FilesBuilder; +use mcp_introspector::Introspector; +use rmcp::handler::server::ServerHandler; +use rmcp::handler::server::tool::ToolRouter; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::{ + CallToolResult, Content, Implementation, ProtocolVersion, ServerCapabilities, ServerInfo, +}; +use rmcp::{ErrorData as McpError, tool, tool_handler, tool_router}; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::Mutex; + +/// MCP server for progressive loading generation. +/// +/// This service helps generate progressive loading TypeScript files for other +/// MCP servers. Claude provides the categorization intelligence through natural +/// language understanding - no separate LLM API needed. +/// +/// # Workflow +/// +/// 1. Call `introspect_server` to discover tools from a target MCP server +/// 2. Claude analyzes the tools and assigns categories, keywords, descriptions +/// 3. Call `save_categorized_tools` to generate TypeScript files +/// 4. Use `list_generated_servers` to see all generated servers +/// +/// # Examples +/// +/// ```no_run +/// use mcp_server::service::GeneratorService; +/// use rmcp::transport::stdio; +/// +/// # async fn example() { +/// let service = GeneratorService::new(); +/// // Service implements rmcp ServerHandler trait +/// # } +/// ``` +#[derive(Debug, Clone)] +pub struct GeneratorService { + /// State manager for pending generations + state: Arc, + + /// MCP server introspector + introspector: Arc>, + + /// Tool router for MCP protocol + tool_router: ToolRouter, +} + +impl GeneratorService { + /// Creates a new generator service. + #[must_use] + pub fn new() -> Self { + Self { + state: Arc::new(StateManager::new()), + introspector: Arc::new(Mutex::new(Introspector::new())), + tool_router: Self::tool_router(), + } + } +} + +impl Default for GeneratorService { + fn default() -> Self { + Self::new() + } +} + +#[tool_router] +impl GeneratorService { + /// Introspect an MCP server and prepare for categorization. + /// + /// Connects to the target MCP server, discovers its tools, and returns + /// metadata for Claude to categorize. Returns a session ID for use with + /// `save_categorized_tools`. + #[tool( + description = "Connect to an MCP server, discover its tools, and return metadata for categorization. Returns a session ID for use with save_categorized_tools." + )] + async fn introspect_server( + &self, + Parameters(params): Parameters, + ) -> Result { + // Validate server_id format + if !params + .server_id + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + { + return Err(McpError::invalid_params( + "server_id must contain only lowercase letters, digits, and hyphens", + None, + )); + } + + // Extract server_id before consuming params + let server_id_str = params.server_id; + let server_id = ServerId::new(&server_id_str); + + // Determine output directory (needs server_id_str) + let output_dir = params.output_dir.unwrap_or_else(|| { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".claude") + .join("servers") + .join(&server_id_str) + }); + + // Build server config (consume args and env to avoid clones) + let mut config_builder = ServerConfig::builder().command(params.command); + + for arg in params.args { + config_builder = config_builder.arg(arg); + } + + for (key, value) in params.env { + config_builder = config_builder.env(key, value); + } + + let config = config_builder.build(); + + // Connect and introspect + let server_info = { + let mut introspector = self.introspector.lock().await; + introspector + .discover_server(server_id.clone(), &config) + .await + .map_err(|e| { + McpError::internal_error(format!("Failed to introspect server: {e}"), None) + })? + }; + + // Extract tool metadata for Claude + let tools: Vec = server_info + .tools + .iter() + .map(|tool| { + let parameters = extract_parameter_names(&tool.input_schema); + + ToolMetadata { + name: tool.name.as_str().to_string(), + description: tool.description.clone(), + parameters, + } + }) + .collect(); + + // Store pending generation + let pending = + PendingGeneration::new(server_id, server_info.clone(), config, output_dir.clone()); + + let session_id = self.state.store(pending.clone()).await; + + // Build result + let result = IntrospectServerResult { + server_id: server_id_str, + server_name: server_info.name, + tools_found: tools.len(), + tools, + session_id, + expires_at: pending.expires_at, + }; + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&result).map_err(|e| { + McpError::internal_error(format!("Failed to serialize result: {e}"), None) + })?, + )])) + } + + /// Save categorized tools as TypeScript files. + /// + /// Generates progressive loading TypeScript files using Claude's + /// categorization. Requires `session_id` from a previous `introspect_server` + /// call. + #[tool( + description = "Generate progressive loading TypeScript files using Claude's categorization. Requires session_id from a previous introspect_server call." + )] + async fn save_categorized_tools( + &self, + Parameters(params): Parameters, + ) -> Result { + // Retrieve pending generation + let pending = self.state.take(params.session_id).await.ok_or_else(|| { + McpError::invalid_params( + "Session not found or expired. Please run introspect_server again.", + None, + ) + })?; + + // Validate categorized tools match introspected tools + let introspected_names: HashSet<_> = pending + .server_info + .tools + .iter() + .map(|t| t.name.as_str()) + .collect(); + + for cat_tool in ¶ms.categorized_tools { + if !introspected_names.contains(cat_tool.name.as_str()) { + return Err(McpError::invalid_params( + format!("Tool '{}' not found in introspected tools", cat_tool.name), + None, + )); + } + } + + // Build categorization map and category stats in single pass (avoid double iteration) + let tool_count = params.categorized_tools.len(); + let mut categorization: HashMap = + HashMap::with_capacity(tool_count); + let mut categories: HashMap = HashMap::with_capacity(tool_count); + + for tool in ¶ms.categorized_tools { + categorization.insert(tool.name.clone(), tool); + *categories.entry(tool.category.clone()).or_default() += 1; + } + + // Generate code with categorization + let generator = ProgressiveGenerator::new().map_err(|e| { + McpError::internal_error(format!("Failed to create generator: {e}"), None) + })?; + + let code = generate_with_categorization(&generator, &pending.server_info, &categorization) + .map_err(|e| McpError::internal_error(format!("Failed to generate code: {e}"), None))?; + + // Build virtual filesystem + let vfs = FilesBuilder::from_generated_code(code, "/") + .build() + .map_err(|e| McpError::internal_error(format!("Failed to build VFS: {e}"), None))?; + + // Capture file count before moving vfs + let files_generated = vfs.file_count(); + + // Ensure output directory exists (async) + tokio::fs::create_dir_all(&pending.output_dir) + .await + .map_err(|e| { + McpError::internal_error(format!("Failed to create output directory: {e}"), None) + })?; + + // Export to filesystem (blocking operation wrapped in spawn_blocking) + let output_dir = pending.output_dir.clone(); + tokio::task::spawn_blocking(move || vfs.export_to_filesystem(&output_dir)) + .await + .map_err(|e| McpError::internal_error(format!("Task join error: {e}"), None))? + .map_err(|e| McpError::internal_error(format!("Failed to export files: {e}"), None))?; + + let result = SaveCategorizedToolsResult { + success: true, + files_generated, + output_dir: pending.output_dir.display().to_string(), + categories, + errors: vec![], + }; + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&result).map_err(|e| { + McpError::internal_error(format!("Failed to serialize result: {e}"), None) + })?, + )])) + } + + /// List all servers with generated progressive loading files. + /// + /// Scans the output directory (default: `~/.claude/servers`) for servers + /// that have generated TypeScript files. + #[tool( + description = "List all MCP servers that have generated progressive loading files in ~/.claude/servers/" + )] + async fn list_generated_servers( + &self, + Parameters(params): Parameters, + ) -> Result { + let base_dir = params.base_dir.map_or_else( + || { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".claude") + .join("servers") + }, + PathBuf::from, + ); + + // Scan directories (blocking operation wrapped in spawn_blocking) + let servers = tokio::task::spawn_blocking(move || { + let mut servers = Vec::new(); + + if base_dir.exists() + && base_dir.is_dir() + && let Ok(entries) = std::fs::read_dir(&base_dir) + { + for entry in entries.flatten() { + if entry.path().is_dir() { + let id = entry.file_name().to_string_lossy().to_string(); + + // Count .ts files (excluding _runtime and starting with _) + let tool_count = std::fs::read_dir(entry.path()) + .map(|e| { + e.flatten() + .filter(|f| { + let name = f.file_name(); + let name = name.to_string_lossy(); + name.ends_with(".ts") && !name.starts_with('_') + }) + .count() + }) + .unwrap_or(0); + + // Get modification time + let generated_at = entry + .metadata() + .and_then(|m| m.modified()) + .ok() + .map(chrono::DateTime::::from); + + servers.push(GeneratedServerInfo { + id, + tool_count, + generated_at, + output_dir: entry.path().display().to_string(), + }); + } + } + } + + servers.sort_by(|a, b| a.id.cmp(&b.id)); + servers + }) + .await + .map_err(|e| McpError::internal_error(format!("Task join error: {e}"), None))?; + + let result = ListGeneratedServersResult { + total_servers: servers.len(), + servers, + }; + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&result).map_err(|e| { + McpError::internal_error(format!("Failed to serialize result: {e}"), None) + })?, + )])) + } +} + +#[tool_handler] +impl ServerHandler for GeneratorService { + fn get_info(&self) -> ServerInfo { + ServerInfo { + protocol_version: ProtocolVersion::V_2024_11_05, + capabilities: ServerCapabilities::builder().enable_tools().build(), + server_info: Implementation::from_build_env(), + instructions: Some( + "Generate progressive loading TypeScript files for MCP servers. \ + Use introspect_server to discover tools, then save_categorized_tools \ + with your categorization." + .to_string(), + ), + } + } +} + +// ============================================================================ +// Helper functions +// ============================================================================ + +/// Extracts parameter names from a JSON Schema. +fn extract_parameter_names(schema: &serde_json::Value) -> Vec { + schema + .get("properties") + .and_then(|p| p.as_object()) + .map(|props| props.keys().cloned().collect()) + .unwrap_or_default() +} + +/// Generates code with categorization metadata. +/// +/// Converts the categorization map to the format expected by the generator +/// and calls `generate_with_categories`. +fn generate_with_categorization( + generator: &ProgressiveGenerator, + server_info: &mcp_introspector::ServerInfo, + categorization: &HashMap, +) -> mcp_core::Result { + use mcp_codegen::progressive::ToolCategorization; + + // Convert CategorizedTool map to ToolCategorization map + let categorizations: HashMap = categorization + .iter() + .map(|(tool_name, cat_tool)| { + ( + tool_name.clone(), + ToolCategorization { + category: cat_tool.category.clone(), + keywords: cat_tool.keywords.clone(), + short_description: cat_tool.short_description.clone(), + }, + ) + }) + .collect(); + + generator.generate_with_categories(server_info, &categorizations) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + use mcp_core::ToolName; + use mcp_introspector::{ServerCapabilities, ToolInfo}; + use rmcp::model::ErrorCode; + use uuid::Uuid; + + // ======================================================================== + // Helper Functions Tests + // ======================================================================== + + #[test] + fn test_extract_parameter_names() { + let schema = serde_json::json!({ + "type": "object", + "properties": { + "name": { "type": "string" }, + "age": { "type": "number" } + } + }); + + let params = extract_parameter_names(&schema); + assert_eq!(params.len(), 2); + assert!(params.contains(&"name".to_string())); + assert!(params.contains(&"age".to_string())); + } + + #[test] + fn test_extract_parameter_names_empty() { + let schema = serde_json::json!({ + "type": "object" + }); + + let params = extract_parameter_names(&schema); + assert_eq!(params.len(), 0); + } + + #[test] + fn test_extract_parameter_names_no_properties() { + let schema = serde_json::json!({ + "type": "string" + }); + + let params = extract_parameter_names(&schema); + assert_eq!(params.len(), 0); + } + + #[test] + fn test_extract_parameter_names_nested_object() { + let schema = serde_json::json!({ + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "name": { "type": "string" } + } + }, + "age": { "type": "number" } + } + }); + + let params = extract_parameter_names(&schema); + assert_eq!(params.len(), 2); + assert!(params.contains(&"user".to_string())); + assert!(params.contains(&"age".to_string())); + } + + #[test] + fn test_generate_with_categorization() { + let generator = ProgressiveGenerator::new().unwrap(); + + let server_info = mcp_introspector::ServerInfo { + id: ServerId::new("test"), + name: "Test Server".to_string(), + version: "1.0.0".to_string(), + capabilities: ServerCapabilities { + supports_tools: true, + supports_resources: false, + supports_prompts: false, + }, + tools: vec![ToolInfo { + name: ToolName::new("test_tool"), + description: "Test tool description".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "param1": { "type": "string" } + } + }), + output_schema: None, + }], + }; + + let categorized_tool = CategorizedTool { + name: "test_tool".to_string(), + category: "testing".to_string(), + keywords: "test,tool".to_string(), + short_description: "Test tool for testing".to_string(), + }; + + let mut categorization = HashMap::new(); + categorization.insert("test_tool".to_string(), &categorized_tool); + + let result = generate_with_categorization(&generator, &server_info, &categorization); + assert!(result.is_ok()); + + let code = result.unwrap(); + assert!(code.file_count() > 0, "Should generate at least one file"); + } + + #[test] + fn test_generate_with_categorization_multiple_tools() { + let generator = ProgressiveGenerator::new().unwrap(); + + let server_info = mcp_introspector::ServerInfo { + id: ServerId::new("test"), + name: "Test Server".to_string(), + version: "1.0.0".to_string(), + capabilities: ServerCapabilities { + supports_tools: true, + supports_resources: false, + supports_prompts: false, + }, + tools: vec![ + ToolInfo { + name: ToolName::new("tool1"), + description: "First tool".to_string(), + input_schema: serde_json::json!({"type": "object"}), + output_schema: None, + }, + ToolInfo { + name: ToolName::new("tool2"), + description: "Second tool".to_string(), + input_schema: serde_json::json!({"type": "object"}), + output_schema: None, + }, + ], + }; + + let tool1 = CategorizedTool { + name: "tool1".to_string(), + category: "category1".to_string(), + keywords: "test".to_string(), + short_description: "Tool 1".to_string(), + }; + + let tool2 = CategorizedTool { + name: "tool2".to_string(), + category: "category2".to_string(), + keywords: "test".to_string(), + short_description: "Tool 2".to_string(), + }; + + let mut categorization = HashMap::new(); + categorization.insert("tool1".to_string(), &tool1); + categorization.insert("tool2".to_string(), &tool2); + + let result = generate_with_categorization(&generator, &server_info, &categorization); + assert!(result.is_ok()); + } + + #[test] + fn test_generate_with_categorization_empty_tools() { + let generator = ProgressiveGenerator::new().unwrap(); + + let server_id = ServerId::new("test"); + let server_info = mcp_introspector::ServerInfo { + id: server_id, + name: "Empty Server".to_string(), + version: "1.0.0".to_string(), + capabilities: ServerCapabilities { + supports_tools: true, + supports_resources: false, + supports_prompts: false, + }, + tools: vec![], + }; + + let categorization = HashMap::new(); + + let result = generate_with_categorization(&generator, &server_info, &categorization); + assert!(result.is_ok()); + } + + // ======================================================================== + // Service Tests + // ======================================================================== + + #[test] + fn test_generator_service_new() { + let service = GeneratorService::new(); + assert!(service.introspector.try_lock().is_ok()); + } + + #[test] + fn test_generator_service_default() { + let service = GeneratorService::default(); + assert!(service.introspector.try_lock().is_ok()); + } + + #[test] + fn test_get_info() { + let service = GeneratorService::new(); + let info = service.get_info(); + + assert_eq!(info.protocol_version, ProtocolVersion::V_2024_11_05); + assert!(info.capabilities.tools.is_some()); + assert!(info.instructions.is_some()); + } + + // ======================================================================== + // Input Validation Tests + // ======================================================================== + + #[tokio::test] + async fn test_introspect_server_invalid_server_id_uppercase() { + let service = GeneratorService::new(); + + let params = IntrospectServerParams { + server_id: "GitHub".to_string(), // Invalid: contains uppercase + command: "echo".to_string(), + args: vec![], + env: HashMap::new(), + output_dir: None, + }; + + let result = service.introspect_server(Parameters(params)).await; + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.code, ErrorCode::INVALID_PARAMS); // Invalid params error code + } + + #[tokio::test] + async fn test_introspect_server_invalid_server_id_underscore() { + let service = GeneratorService::new(); + + let params = IntrospectServerParams { + server_id: "git_hub".to_string(), // Invalid: contains underscore + command: "echo".to_string(), + args: vec![], + env: HashMap::new(), + output_dir: None, + }; + + let result = service.introspect_server(Parameters(params)).await; + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.code, ErrorCode::INVALID_PARAMS); + } + + #[tokio::test] + async fn test_introspect_server_invalid_server_id_special_chars() { + let service = GeneratorService::new(); + + let params = IntrospectServerParams { + server_id: "git@hub".to_string(), // Invalid: contains @ + command: "echo".to_string(), + args: vec![], + env: HashMap::new(), + output_dir: None, + }; + + let result = service.introspect_server(Parameters(params)).await; + + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_introspect_server_valid_server_id_with_hyphens() { + let service = GeneratorService::new(); + + let params = IntrospectServerParams { + server_id: "git-hub-server".to_string(), // Valid + command: "echo".to_string(), + args: vec!["test".to_string()], + env: HashMap::new(), + output_dir: None, + }; + + // This will fail because echo is not an MCP server, but validation should pass + let result = service.introspect_server(Parameters(params)).await; + + // Should fail with internal error (connection), not invalid params + if let Err(err) = result { + assert_ne!( + err.code, + ErrorCode::INVALID_PARAMS, + "Should not be invalid params error" + ); + } + } + + #[tokio::test] + async fn test_introspect_server_valid_server_id_digits() { + let service = GeneratorService::new(); + + let params = IntrospectServerParams { + server_id: "server123".to_string(), // Valid: lowercase + digits + command: "echo".to_string(), + args: vec![], + env: HashMap::new(), + output_dir: None, + }; + + let result = service.introspect_server(Parameters(params)).await; + + // Should fail with internal error (connection), not invalid params + if let Err(err) = result { + assert_ne!(err.code, ErrorCode::INVALID_PARAMS); + } + } + + // ======================================================================== + // save_categorized_tools Error Tests + // ======================================================================== + + #[tokio::test] + async fn test_save_categorized_tools_invalid_session() { + let service = GeneratorService::new(); + + let params = SaveCategorizedToolsParams { + session_id: Uuid::new_v4(), // Random UUID not in state + categorized_tools: vec![], + }; + + let result = service.save_categorized_tools(Parameters(params)).await; + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.code, ErrorCode::INVALID_PARAMS); // Invalid params + assert!(err.message.contains("Session not found")); + } + + #[tokio::test] + async fn test_save_categorized_tools_tool_mismatch() { + let service = GeneratorService::new(); + + // Create a pending generation with tool1 + let server_id = ServerId::new("test"); + let server_info = mcp_introspector::ServerInfo { + id: server_id.clone(), + name: "Test".to_string(), + version: "1.0.0".to_string(), + capabilities: ServerCapabilities { + supports_tools: true, + supports_resources: false, + supports_prompts: false, + }, + tools: vec![ToolInfo { + name: ToolName::new("tool1"), + description: "Tool 1".to_string(), + input_schema: serde_json::json!({"type": "object"}), + output_schema: None, + }], + }; + + let pending = PendingGeneration::new( + server_id, + server_info, + ServerConfig::builder().command("echo".to_string()).build(), + PathBuf::from("/tmp/test"), + ); + + let session_id = service.state.store(pending).await; + + // Try to save with tool2 (doesn't exist) + let params = SaveCategorizedToolsParams { + session_id, + categorized_tools: vec![CategorizedTool { + name: "tool2".to_string(), // Mismatch! + category: "test".to_string(), + keywords: "test".to_string(), + short_description: "Test".to_string(), + }], + }; + + let result = service.save_categorized_tools(Parameters(params)).await; + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.code, ErrorCode::INVALID_PARAMS); + assert!(err.message.contains("not found in introspected tools")); + } + + #[tokio::test] + async fn test_save_categorized_tools_expired_session() { + use chrono::Duration; + + let service = GeneratorService::new(); + + // Create an expired pending generation + let server_id = ServerId::new("test"); + let server_info = mcp_introspector::ServerInfo { + id: server_id.clone(), + name: "Test".to_string(), + version: "1.0.0".to_string(), + capabilities: ServerCapabilities { + supports_tools: true, + supports_resources: false, + supports_prompts: false, + }, + tools: vec![], + }; + + let mut pending = PendingGeneration::new( + server_id, + server_info, + ServerConfig::builder().command("echo".to_string()).build(), + PathBuf::from("/tmp/test"), + ); + + // Manually expire it + pending.expires_at = Utc::now() - Duration::hours(1); + + let session_id = service.state.store(pending).await; + + let params = SaveCategorizedToolsParams { + session_id, + categorized_tools: vec![], + }; + + let result = service.save_categorized_tools(Parameters(params)).await; + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.code, ErrorCode::INVALID_PARAMS); + } + + // ======================================================================== + // list_generated_servers Tests + // ======================================================================== + + #[tokio::test] + async fn test_list_generated_servers_nonexistent_dir() { + let service = GeneratorService::new(); + + let params = ListGeneratedServersParams { + base_dir: Some("/nonexistent/path/that/does/not/exist".to_string()), + }; + + let result = service.list_generated_servers(Parameters(params)).await; + + assert!(result.is_ok()); + let content = result.unwrap(); + let text_content = content.content[0].as_text().unwrap(); + let parsed: ListGeneratedServersResult = serde_json::from_str(&text_content.text).unwrap(); + + assert_eq!(parsed.total_servers, 0); + assert_eq!(parsed.servers.len(), 0); + } + + #[tokio::test] + async fn test_list_generated_servers_default_dir() { + let service = GeneratorService::new(); + + let params = ListGeneratedServersParams { base_dir: None }; + + let result = service.list_generated_servers(Parameters(params)).await; + + // Should succeed even if directory doesn't exist + assert!(result.is_ok()); + } +} diff --git a/crates/mcp-server/src/state.rs b/crates/mcp-server/src/state.rs new file mode 100644 index 0000000..4e22a10 --- /dev/null +++ b/crates/mcp-server/src/state.rs @@ -0,0 +1,384 @@ +//! State management for pending generation sessions. +//! +//! The `StateManager` stores temporary session data between `introspect_server` +//! and `save_categorized_tools` calls. Sessions expire after 30 minutes and +//! are cleaned up lazily on each operation. + +use crate::types::PendingGeneration; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; +use uuid::Uuid; + +/// State manager for pending generation sessions. +/// +/// Uses an in-memory `HashMap` protected by `RwLock` for thread-safe access. +/// Sessions expire after 30 minutes and are cleaned up lazily. +/// +/// # Examples +/// +/// ``` +/// use mcp_server::state::StateManager; +/// use mcp_server::types::PendingGeneration; +/// use mcp_core::{ServerId, ServerConfig}; +/// use mcp_introspector::ServerInfo; +/// use std::path::PathBuf; +/// +/// # async fn example() { +/// let state = StateManager::new(); +/// +/// # let server_info = ServerInfo { +/// # id: ServerId::new("test"), +/// # name: "Test".to_string(), +/// # version: "1.0.0".to_string(), +/// # capabilities: mcp_introspector::ServerCapabilities { +/// # supports_tools: true, +/// # supports_resources: false, +/// # supports_prompts: false, +/// # }, +/// # tools: vec![], +/// # }; +/// let pending = PendingGeneration::new( +/// ServerId::new("github"), +/// server_info, +/// ServerConfig::builder().command("npx".to_string()).build(), +/// PathBuf::from("/tmp/output"), +/// ); +/// +/// // Store and get session ID +/// let session_id = state.store(pending).await; +/// +/// // Retrieve session data +/// let retrieved = state.take(session_id).await; +/// assert!(retrieved.is_some()); +/// # } +/// ``` +#[derive(Debug, Default)] +pub struct StateManager { + pending: Arc>>, +} + +impl StateManager { + /// Creates a new state manager. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Stores a pending generation and returns a session ID. + /// + /// This operation also performs lazy cleanup of expired sessions. + /// + /// # Examples + /// + /// ``` + /// use mcp_server::state::StateManager; + /// # use mcp_server::types::PendingGeneration; + /// # use mcp_core::{ServerId, ServerConfig}; + /// # use mcp_introspector::ServerInfo; + /// # use std::path::PathBuf; + /// + /// # async fn example(pending: PendingGeneration) { + /// let state = StateManager::new(); + /// let session_id = state.store(pending).await; + /// # } + /// ``` + pub async fn store(&self, generation: PendingGeneration) -> Uuid { + let session_id = Uuid::new_v4(); + let mut pending = self.pending.write().await; + + // Clean up expired sessions + pending.retain(|_, g| !g.is_expired()); + + pending.insert(session_id, generation); + session_id + } + + /// Retrieves and removes a pending generation. + /// + /// Returns `None` if the session is not found or has expired. + /// This operation also performs lazy cleanup of expired sessions. + /// + /// # Examples + /// + /// ``` + /// use mcp_server::state::StateManager; + /// # use mcp_server::types::PendingGeneration; + /// # use mcp_core::{ServerId, ServerConfig}; + /// # use mcp_introspector::ServerInfo; + /// # use std::path::PathBuf; + /// + /// # async fn example(pending: PendingGeneration) { + /// let state = StateManager::new(); + /// let session_id = state.store(pending).await; + /// + /// let retrieved = state.take(session_id).await; + /// assert!(retrieved.is_some()); + /// + /// // Second take returns None (already removed) + /// let second = state.take(session_id).await; + /// assert!(second.is_none()); + /// # } + /// ``` + pub async fn take(&self, session_id: Uuid) -> Option { + let generation = { + let mut pending = self.pending.write().await; + + // Clean up expired sessions + pending.retain(|_, g| !g.is_expired()); + + pending.remove(&session_id)? + }; + + // Verify not expired (lock already released) + if generation.is_expired() { + return None; + } + + Some(generation) + } + + /// Gets a pending generation without removing it. + /// + /// Returns `None` if the session is not found or has expired. + /// + /// # Examples + /// + /// ``` + /// use mcp_server::state::StateManager; + /// # use mcp_server::types::PendingGeneration; + /// # use mcp_core::{ServerId, ServerConfig}; + /// # use mcp_introspector::ServerInfo; + /// # use std::path::PathBuf; + /// + /// # async fn example(pending: PendingGeneration) { + /// let state = StateManager::new(); + /// let session_id = state.store(pending).await; + /// + /// // Get without removing + /// let peeked = state.get(session_id).await; + /// assert!(peeked.is_some()); + /// + /// // Still available + /// let peeked_again = state.get(session_id).await; + /// assert!(peeked_again.is_some()); + /// # } + /// ``` + pub async fn get(&self, session_id: Uuid) -> Option { + let pending = self.pending.read().await; + pending + .get(&session_id) + .filter(|g| !g.is_expired()) + .cloned() + } + + /// Returns the current pending session count (excluding expired). + /// + /// # Examples + /// + /// ``` + /// use mcp_server::state::StateManager; + /// + /// # async fn example() { + /// let state = StateManager::new(); + /// assert_eq!(state.pending_count().await, 0); + /// # } + /// ``` + pub async fn pending_count(&self) -> usize { + let pending = self.pending.read().await; + pending.values().filter(|g| !g.is_expired()).count() + } + + /// Cleans up all expired sessions. + /// + /// Returns the number of sessions that were removed. + /// + /// # Examples + /// + /// ``` + /// use mcp_server::state::StateManager; + /// + /// # async fn example() { + /// let state = StateManager::new(); + /// let removed = state.cleanup_expired().await; + /// assert_eq!(removed, 0); + /// # } + /// ``` + pub async fn cleanup_expired(&self) -> usize { + let mut pending = self.pending.write().await; + let before = pending.len(); + pending.retain(|_, g| !g.is_expired()); + before - pending.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::PendingGeneration; + use chrono::{Duration, Utc}; + use mcp_core::{ServerConfig, ServerId, ToolName}; + use mcp_introspector::ServerInfo; + use std::path::PathBuf; + + fn create_test_pending() -> PendingGeneration { + use mcp_introspector::{ServerCapabilities, ToolInfo}; + + let server_id = ServerId::new("test"); + let server_info = ServerInfo { + id: server_id.clone(), + name: "Test Server".to_string(), + version: "1.0.0".to_string(), + capabilities: ServerCapabilities { + supports_tools: true, + supports_resources: false, + supports_prompts: false, + }, + tools: vec![ToolInfo { + name: ToolName::new("test_tool"), + description: "Test tool".to_string(), + input_schema: serde_json::json!({}), + output_schema: None, + }], + }; + let config = ServerConfig::builder().command("echo".to_string()).build(); + let output_dir = PathBuf::from("/tmp/test"); + + PendingGeneration::new(server_id, server_info, config, output_dir) + } + + fn create_expired_pending() -> PendingGeneration { + let mut pending = create_test_pending(); + pending.expires_at = Utc::now() - Duration::hours(1); + pending + } + + #[tokio::test] + async fn test_store_and_retrieve() { + let state = StateManager::new(); + let pending = create_test_pending(); + + let session_id = state.store(pending.clone()).await; + let retrieved = state.take(session_id).await; + + assert!(retrieved.is_some()); + let retrieved = retrieved.unwrap(); + assert_eq!(retrieved.server_id, pending.server_id); + } + + #[tokio::test] + async fn test_take_removes_session() { + let state = StateManager::new(); + let pending = create_test_pending(); + + let session_id = state.store(pending).await; + + // First take succeeds + let first = state.take(session_id).await; + assert!(first.is_some()); + + // Second take returns None + let second = state.take(session_id).await; + assert!(second.is_none()); + } + + #[tokio::test] + async fn test_get_does_not_remove() { + let state = StateManager::new(); + let pending = create_test_pending(); + + let session_id = state.store(pending).await; + + // Get multiple times + let first = state.get(session_id).await; + assert!(first.is_some()); + + let second = state.get(session_id).await; + assert!(second.is_some()); + + // Still available for take + let taken = state.take(session_id).await; + assert!(taken.is_some()); + } + + #[tokio::test] + async fn test_expired_session() { + let state = StateManager::new(); + let pending = create_expired_pending(); + + let session_id = state.store(pending).await; + + // Should return None because expired + let retrieved = state.take(session_id).await; + assert!(retrieved.is_none()); + } + + #[tokio::test] + async fn test_pending_count() { + let state = StateManager::new(); + + assert_eq!(state.pending_count().await, 0); + + let session_id = state.store(create_test_pending()).await; + assert_eq!(state.pending_count().await, 1); + + state.take(session_id).await; + assert_eq!(state.pending_count().await, 0); + } + + #[tokio::test] + async fn test_cleanup_expired() { + let state = StateManager::new(); + + // Add valid session + state.store(create_test_pending()).await; + + // Add expired session + state.store(create_expired_pending()).await; + + assert_eq!(state.pending_count().await, 1); // Only valid session counts + + let removed = state.cleanup_expired().await; + assert_eq!(removed, 1); // One expired session removed + } + + #[tokio::test] + async fn test_concurrent_access() { + let state = Arc::new(StateManager::new()); + let mut handles = vec![]; + + // Spawn 10 concurrent store operations + for i in 0..10 { + let state_clone = Arc::clone(&state); + handles.push(tokio::spawn(async move { + let mut pending = create_test_pending(); + pending.server_id = ServerId::new(&format!("server-{i}")); + state_clone.store(pending).await + })); + } + + // Wait for all operations to complete + for handle in handles { + handle.await.unwrap(); + } + + assert_eq!(state.pending_count().await, 10); + } + + #[tokio::test] + async fn test_lazy_cleanup_on_store() { + let state = StateManager::new(); + + // Store expired session directly + { + let mut pending = state.pending.write().await; + pending.insert(Uuid::new_v4(), create_expired_pending()); + } + + // Store new session triggers cleanup + state.store(create_test_pending()).await; + + // Only the new session should remain + assert_eq!(state.pending_count().await, 1); + } +} diff --git a/crates/mcp-server/src/types.rs b/crates/mcp-server/src/types.rs new file mode 100644 index 0000000..abe7c87 --- /dev/null +++ b/crates/mcp-server/src/types.rs @@ -0,0 +1,377 @@ +//! Type definitions for MCP server tools. +//! +//! This module defines all parameter and result types for the three main tools: +//! - `introspect_server`: Connect to and introspect an MCP server +//! - `save_categorized_tools`: Generate TypeScript files with categorization +//! - `list_generated_servers`: List all servers with generated files + +use chrono::{DateTime, Utc}; +use mcp_core::{ServerConfig, ServerId}; +use mcp_introspector::ServerInfo; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; +use uuid::Uuid; + +// ============================================================================ +// introspect_server types +// ============================================================================ + +/// Parameters for introspecting an MCP server. +/// +/// # Examples +/// +/// ``` +/// use mcp_server::types::IntrospectServerParams; +/// use std::collections::HashMap; +/// +/// let params = IntrospectServerParams { +/// server_id: "github".to_string(), +/// command: "npx".to_string(), +/// args: vec!["-y".to_string(), "@anthropic/mcp-server-github".to_string()], +/// env: HashMap::new(), +/// output_dir: None, +/// }; +/// ``` +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct IntrospectServerParams { + /// Unique identifier for the server (e.g., "github", "filesystem") + pub server_id: String, + + /// Command to start the server (e.g., "npx", "docker") + pub command: String, + + /// Arguments to pass to the command + #[serde(default)] + pub args: Vec, + + /// Environment variables for the server process + #[serde(default)] + pub env: HashMap, + + /// Custom output directory (default: `~/.claude/servers/{server_id}`) + pub output_dir: Option, +} + +/// Result from introspecting an MCP server. +/// +/// Contains tool metadata for Claude to categorize and a session ID +/// for use with `save_categorized_tools`. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct IntrospectServerResult { + /// Server identifier + pub server_id: String, + + /// Human-readable server name + pub server_name: String, + + /// Number of tools discovered + pub tools_found: usize, + + /// List of tools for categorization + pub tools: Vec, + + /// Session ID for `save_categorized_tools` call + pub session_id: Uuid, + + /// Session expiration time (ISO 8601) + pub expires_at: DateTime, +} + +/// Metadata about a tool for categorization by Claude. +/// +/// Includes the tool name, description, and parameter names to help +/// Claude understand the tool's purpose and assign appropriate categories. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct ToolMetadata { + /// Original tool name + pub name: String, + + /// Tool description from server + pub description: String, + + /// Parameter names for context + pub parameters: Vec, +} + +// ============================================================================ +// save_categorized_tools types +// ============================================================================ + +/// Parameters for saving categorized tools. +/// +/// # Examples +/// +/// ``` +/// use mcp_server::types::{SaveCategorizedToolsParams, CategorizedTool}; +/// use uuid::Uuid; +/// +/// let params = SaveCategorizedToolsParams { +/// session_id: Uuid::new_v4(), +/// categorized_tools: vec![ +/// CategorizedTool { +/// name: "create_issue".to_string(), +/// category: "issues".to_string(), +/// keywords: "create,issue,new,bug,feature".to_string(), +/// short_description: "Create a new issue in a repository".to_string(), +/// }, +/// ], +/// }; +/// ``` +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct SaveCategorizedToolsParams { + /// Session ID from `introspect_server` call + pub session_id: Uuid, + + /// Tools with Claude's categorization + pub categorized_tools: Vec, +} + +/// A tool with categorization metadata from Claude. +/// +/// Claude analyzes the tool's purpose and provides: +/// - A category for grouping related tools +/// - Keywords for discovery via grep/search +/// - A concise description for file headers +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct CategorizedTool { + /// Original tool name (must match introspected tool) + pub name: String, + + /// Category assigned by Claude (e.g., "issues", "repos", "users") + pub category: String, + + /// Comma-separated keywords for discovery + pub keywords: String, + + /// Concise description (max 80 chars) for header comment + pub short_description: String, +} + +/// Result from saving categorized tools. +/// +/// Reports success status, number of files generated, and any errors +/// that occurred during generation. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct SaveCategorizedToolsResult { + /// Whether generation succeeded + pub success: bool, + + /// Number of TypeScript files created + pub files_generated: usize, + + /// Directory where files were written + pub output_dir: String, + + /// Count of tools per category + pub categories: HashMap, + + /// Any tools that failed to generate + #[serde(skip_serializing_if = "Vec::is_empty")] + pub errors: Vec, +} + +/// Error that occurred while generating a specific tool. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct ToolGenerationError { + /// Name of the tool that failed + pub tool_name: String, + + /// Error message + pub error: String, +} + +// ============================================================================ +// list_generated_servers types +// ============================================================================ + +/// Parameters for listing generated servers. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct ListGeneratedServersParams { + /// Base directory to scan (default: `~/.claude/servers`) + pub base_dir: Option, +} + +/// Result from listing generated servers. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ListGeneratedServersResult { + /// List of servers with generated files + pub servers: Vec, + + /// Total number of servers found + pub total_servers: usize, +} + +/// Information about a server with generated progressive loading files. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct GeneratedServerInfo { + /// Server identifier + pub id: String, + + /// Number of tool files (excluding runtime) + pub tool_count: usize, + + /// Last generation timestamp + pub generated_at: Option>, + + /// Directory path + pub output_dir: String, +} + +// ============================================================================ +// State management types +// ============================================================================ + +/// Pending generation session. +/// +/// Stores introspection data between `introspect_server` and +/// `save_categorized_tools` calls. +#[derive(Debug, Clone)] +pub struct PendingGeneration { + /// Server identifier + pub server_id: ServerId, + + /// Full server introspection data + pub server_info: ServerInfo, + + /// Server configuration for regeneration if needed + pub config: ServerConfig, + + /// Output directory + pub output_dir: PathBuf, + + /// Session creation time + pub created_at: DateTime, + + /// Session expiration time (30 minutes default) + pub expires_at: DateTime, +} + +impl PendingGeneration { + /// Default session timeout: 30 minutes. + pub const DEFAULT_TIMEOUT_MINUTES: i64 = 30; + + /// Creates a new pending generation session. + /// + /// # Examples + /// + /// ``` + /// use mcp_server::types::PendingGeneration; + /// use mcp_core::{ServerId, ServerConfig}; + /// use mcp_introspector::ServerInfo; + /// use std::path::PathBuf; + /// + /// # fn example(server_info: ServerInfo) { + /// let server_id = ServerId::new("github"); + /// let config = ServerConfig::builder() + /// .command("npx".to_string()) + /// .arg("-y".to_string()) + /// .arg("@anthropic/mcp-server-github".to_string()) + /// .build(); + /// let output_dir = PathBuf::from("/tmp/output"); + /// + /// let pending = PendingGeneration::new( + /// server_id, + /// server_info, + /// config, + /// output_dir, + /// ); + /// # } + /// ``` + #[must_use] + pub fn new( + server_id: ServerId, + server_info: ServerInfo, + config: ServerConfig, + output_dir: PathBuf, + ) -> Self { + let now = Utc::now(); + Self { + server_id, + server_info, + config, + output_dir, + created_at: now, + expires_at: now + chrono::Duration::minutes(Self::DEFAULT_TIMEOUT_MINUTES), + } + } + + /// Checks if this session has expired. + /// + /// # Examples + /// + /// ``` + /// use mcp_server::types::PendingGeneration; + /// # use mcp_core::{ServerId, ServerConfig}; + /// # use mcp_introspector::ServerInfo; + /// # use std::path::PathBuf; + /// + /// # fn example(server_info: ServerInfo) { + /// let pending = PendingGeneration::new( + /// ServerId::new("test"), + /// server_info, + /// ServerConfig::builder().command("echo".to_string()).build(), + /// PathBuf::from("/tmp"), + /// ); + /// + /// assert!(!pending.is_expired()); + /// # } + /// ``` + #[must_use] + pub fn is_expired(&self) -> bool { + Utc::now() > self.expires_at + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pending_generation_not_expired() { + let pending = create_test_pending(); + assert!(!pending.is_expired()); + } + + #[test] + fn test_categorized_tool_serialization() { + let tool = CategorizedTool { + name: "create_issue".to_string(), + category: "issues".to_string(), + keywords: "create,issue,new".to_string(), + short_description: "Create a new issue".to_string(), + }; + + let json = serde_json::to_string(&tool).unwrap(); + let _deserialized: CategorizedTool = serde_json::from_str(&json).unwrap(); + } + + // Test helper + fn create_test_pending() -> PendingGeneration { + use mcp_core::ToolName; + use mcp_introspector::{ServerCapabilities, ToolInfo}; + + let server_id = ServerId::new("test"); + let server_info = ServerInfo { + id: server_id.clone(), + name: "Test Server".to_string(), + version: "1.0.0".to_string(), + capabilities: ServerCapabilities { + supports_tools: true, + supports_resources: false, + supports_prompts: false, + }, + tools: vec![ToolInfo { + name: ToolName::new("test_tool"), + description: "Test tool description".to_string(), + input_schema: serde_json::json!({}), + output_schema: None, + }], + }; + let config = ServerConfig::builder().command("echo".to_string()).build(); + let output_dir = PathBuf::from("/tmp/test"); + + PendingGeneration::new(server_id, server_info, config, output_dir) + } +} diff --git a/crates/mcp-server/tests/integration_tests.rs b/crates/mcp-server/tests/integration_tests.rs new file mode 100644 index 0000000..12c379f --- /dev/null +++ b/crates/mcp-server/tests/integration_tests.rs @@ -0,0 +1,352 @@ +//! Integration tests for mcp-server. +//! +//! These tests verify the public API and state management of the mcp-server crate. +//! Note: MCP tool methods generated by #[tool] macro are private and tested via +//! unit tests in service.rs, so we focus on testing public types and state manager. + +use chrono::Duration; +use mcp_core::{ServerConfig, ServerId, ToolName}; +use mcp_introspector::{ServerCapabilities, ServerInfo, ToolInfo}; +use mcp_server::{CategorizedTool, GeneratorService, PendingGeneration, StateManager}; +use rmcp::handler::server::ServerHandler; +use std::sync::Arc; + +// ============================================================================ +// Service Initialization Tests +// ============================================================================ + +#[test] +fn test_service_info_has_correct_capabilities() { + let service = GeneratorService::new(); + let info = service.get_info(); + + // Verify protocol version + assert_eq!( + info.protocol_version, + rmcp::model::ProtocolVersion::V_2024_11_05 + ); + + // Verify tools are enabled + assert!(info.capabilities.tools.is_some()); + let tools = info.capabilities.tools.as_ref().unwrap(); + assert!(!tools.list_changed.unwrap_or(false)); + + // Verify instructions are provided + assert!(info.instructions.is_some()); + let instructions = info.instructions.unwrap(); + assert!(instructions.contains("progressive loading")); + assert!(instructions.contains("introspect_server")); + assert!(instructions.contains("save_categorized_tools")); +} + +#[test] +fn test_service_new_creates_fresh_instance() { + let service1 = GeneratorService::new(); + let service2 = GeneratorService::new(); + + // Each service should be a separate instance + let info1 = service1.get_info(); + let info2 = service2.get_info(); + + // Both should have the same capabilities + assert_eq!(info1.protocol_version, info2.protocol_version); + assert!(info1.capabilities.tools.is_some()); + assert!(info2.capabilities.tools.is_some()); +} + +// ============================================================================ +// State Management Integration Tests +// ============================================================================ + +#[tokio::test] +async fn test_state_manager_workflow() { + let state = StateManager::new(); + + // Create test server info + let server_id = ServerId::new("test-server"); + let server_info = create_test_server_info(server_id.clone()); + + let config = ServerConfig::builder().command("echo".to_string()).build(); + let output_dir = std::env::temp_dir().join("mcp-server-test"); + + // Store pending generation + let pending = PendingGeneration::new(server_id, server_info, config, output_dir); + let session_id = state.store(pending.clone()).await; + + // Verify it's stored + assert_eq!(state.pending_count().await, 1); + + // Retrieve it + let retrieved = state.take(session_id).await; + assert!(retrieved.is_some(), "Should retrieve stored session"); + assert_eq!(retrieved.unwrap().server_id, pending.server_id); + + // Verify it's consumed + assert_eq!(state.pending_count().await, 0); + + // Second take should fail + let second = state.take(session_id).await; + assert!(second.is_none(), "Session should be consumed"); +} + +#[tokio::test] +async fn test_multiple_concurrent_sessions() { + let state = StateManager::new(); + + // Create multiple pending generations + let mut sessions = Vec::new(); + for i in 0..5 { + let server_id = ServerId::new(&format!("server-{i}")); + let server_info = ServerInfo { + id: server_id.clone(), + name: format!("Server {i}"), + version: "1.0.0".to_string(), + capabilities: ServerCapabilities { + supports_tools: true, + supports_resources: false, + supports_prompts: false, + }, + tools: vec![], + }; + + let config = ServerConfig::builder().command("echo".to_string()).build(); + let output_dir = std::env::temp_dir().join(format!("mcp-test-{i}")); + + let pending = PendingGeneration::new(server_id, server_info, config, output_dir); + let session_id = state.store(pending).await; + sessions.push(session_id); + } + + // Verify all sessions are accessible + assert_eq!(state.pending_count().await, 5); + + // Retrieve each session + for session_id in sessions { + let retrieved = state.take(session_id).await; + assert!(retrieved.is_some(), "Session should be retrievable"); + } + + // All sessions should be consumed + assert_eq!(state.pending_count().await, 0); +} + +#[tokio::test] +async fn test_state_manager_handles_expiration() { + let state = StateManager::new(); + + let server_id = ServerId::new("test"); + let server_info = create_test_server_info(server_id.clone()); + let config = ServerConfig::builder().command("echo".to_string()).build(); + let output_dir = std::env::temp_dir().join("mcp-expire-test"); + + // Create and manually expire a session + let mut pending = PendingGeneration::new(server_id, server_info, config, output_dir); + pending.expires_at = chrono::Utc::now() - Duration::hours(1); + + let session_id = state.store(pending).await; + + // Should not be retrievable (expired) + let retrieved = state.take(session_id).await; + assert!( + retrieved.is_none(), + "Expired session should not be retrievable" + ); + + // Pending count should be 0 (expired sessions are cleaned up) + assert_eq!(state.pending_count().await, 0); +} + +#[tokio::test] +async fn test_state_manager_lazy_cleanup() { + let state = StateManager::new(); + + // Create valid session + let valid_pending = create_test_pending("valid-server"); + state.store(valid_pending).await; + + // Create expired session + let mut expired_pending = create_test_pending("expired-server"); + expired_pending.expires_at = chrono::Utc::now() - Duration::hours(1); + state.store(expired_pending).await; + + // Pending count should only include valid sessions + assert_eq!(state.pending_count().await, 1); + + // Explicit cleanup + let removed = state.cleanup_expired().await; + assert_eq!(removed, 1, "Should remove 1 expired session"); +} + +#[tokio::test] +async fn test_state_manager_get_without_consuming() { + let state = StateManager::new(); + + let pending = create_test_pending("test"); + let session_id = state.store(pending).await; + + // Get without consuming + let first = state.get(session_id).await; + assert!(first.is_some()); + + // Should still be available + let second = state.get(session_id).await; + assert!(second.is_some()); + + // Should still be available for take + let taken = state.take(session_id).await; + assert!(taken.is_some()); + + // Now it should be gone + let gone = state.get(session_id).await; + assert!(gone.is_none()); +} + +// ============================================================================ +// PendingGeneration Tests +// ============================================================================ + +#[test] +fn test_pending_generation_not_expired_initially() { + let pending = create_test_pending("test"); + assert!(!pending.is_expired()); +} + +#[test] +fn test_pending_generation_expires_correctly() { + let mut pending = create_test_pending("test"); + pending.expires_at = chrono::Utc::now() - Duration::minutes(1); + assert!(pending.is_expired()); +} + +#[test] +fn test_pending_generation_has_correct_timeout() { + let pending = create_test_pending("test"); + + let duration = pending.expires_at - pending.created_at; + let minutes = duration.num_minutes(); + + assert_eq!( + minutes, + PendingGeneration::DEFAULT_TIMEOUT_MINUTES, + "Should use default timeout" + ); +} + +// ============================================================================ +// CategorizedTool Tests +// ============================================================================ + +#[test] +fn test_categorized_tool_serialization_roundtrip() { + let tool = CategorizedTool { + name: "test_tool".to_string(), + category: "testing".to_string(), + keywords: "test,tool,demo".to_string(), + short_description: "A test tool".to_string(), + }; + + let json = serde_json::to_string(&tool).unwrap(); + let deserialized: CategorizedTool = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.name, tool.name); + assert_eq!(deserialized.category, tool.category); + assert_eq!(deserialized.keywords, tool.keywords); + assert_eq!(deserialized.short_description, tool.short_description); +} + +// ============================================================================ +// Concurrent Access Tests +// ============================================================================ + +#[tokio::test] +async fn test_state_manager_concurrent_access() { + let state = Arc::new(StateManager::new()); + let mut handles = vec![]; + + // Spawn 10 concurrent store operations + for i in 0..10 { + let state_clone = Arc::clone(&state); + handles.push(tokio::spawn(async move { + let pending = create_test_pending(&format!("server-{i}")); + state_clone.store(pending).await + })); + } + + // Wait for all operations to complete + let mut session_ids = Vec::new(); + for handle in handles { + let session_id = handle.await.unwrap(); + session_ids.push(session_id); + } + + // All sessions should be stored + assert_eq!(state.pending_count().await, 10); + + // All session IDs should be unique + let unique_count = session_ids + .iter() + .collect::>() + .len(); + assert_eq!(unique_count, 10, "All session IDs should be unique"); +} + +#[tokio::test] +async fn test_state_manager_concurrent_read_write() { + let state = Arc::new(StateManager::new()); + + // Store initial session + let pending = create_test_pending("test"); + let session_id = state.store(pending).await; + + let state_clone1 = Arc::clone(&state); + let state_clone2 = Arc::clone(&state); + + // Concurrent reads should work + let handle1 = tokio::spawn(async move { state_clone1.get(session_id).await }); + + let handle2 = tokio::spawn(async move { state_clone2.get(session_id).await }); + + let result1 = handle1.await.unwrap(); + let result2 = handle2.await.unwrap(); + + assert!(result1.is_some()); + assert!(result2.is_some()); +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +fn create_test_server_info(server_id: ServerId) -> ServerInfo { + ServerInfo { + id: server_id, + name: "Test Server".to_string(), + version: "1.0.0".to_string(), + capabilities: ServerCapabilities { + supports_tools: true, + supports_resources: false, + supports_prompts: false, + }, + tools: vec![ToolInfo { + name: ToolName::new("test_tool"), + description: "A test tool".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "message": {"type": "string"} + }, + "required": ["message"] + }), + output_schema: None, + }], + } +} + +fn create_test_pending(server_id_str: &str) -> PendingGeneration { + let server_id = ServerId::new(server_id_str); + let server_info = create_test_server_info(server_id.clone()); + let config = ServerConfig::builder().command("echo".to_string()).build(); + let output_dir = std::env::temp_dir().join(format!("mcp-test-{server_id_str}")); + + PendingGeneration::new(server_id, server_info, config, output_dir) +}