diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9248f2f..7a03f630 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,13 @@ jobs: with: targets: ${{ matrix.target || '' }} + # The generated TypeScript declarations are type-checked with `tsc`. + # That test skips when no Node is reachable, so it must not skip here. + - name: Install Node + uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Install musl tools if: matrix.target == 'x86_64-unknown-linux-musl' run: | diff --git a/Cargo.lock b/Cargo.lock index af2134d1..4bd9e267 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -56,6 +56,20 @@ dependencies = [ "strum", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -65,6 +79,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -136,6 +156,17 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -187,6 +218,27 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.11.0" @@ -202,6 +254,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "boxfnonce" version = "0.1.1" @@ -214,6 +272,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytes" version = "1.11.1" @@ -407,6 +471,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -512,6 +585,12 @@ dependencies = [ "syn", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "deflate64" version = "0.1.12" @@ -542,7 +621,7 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "convert_case", + "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version", @@ -623,6 +702,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -654,6 +742,25 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "expect-test" version = "1.5.1" @@ -664,6 +771,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "fancy-regex" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "476de73bddf2ef8490aa4ee8f1cf40b430bf1d56c48c22080e5186952cd580e6" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -704,6 +822,17 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "fnv" version = "1.0.7" @@ -716,6 +845,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -725,6 +860,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -922,7 +1067,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -931,6 +1076,17 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + [[package]] name = "heck" version = "0.5.0" @@ -1039,7 +1195,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -1328,6 +1484,58 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jsonschema" +version = "0.49.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ec8a241beed129f06114aa68007e905ca350e7baeb6e17a7631bb7978d91b2" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "jsonschema-regex", + "jsonschema-value", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "serde", + "serde_json", + "strum", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.49.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91994f45017ed5e66aa8e59b8415f4cb033a6380d7200387b7cf117595fbdf85" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "jsonschema-value" +version = "0.49.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ec7637f83e510868ae6ed625f7ebfbbde4554ee8ce49854caa5126a8b9b9ecb" +dependencies = [ + "ahash", + "bytecount", + "fraction", + "num-cmp", + "num-traits", + "serde_json", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1348,9 +1556,9 @@ checksum = "b3a6a8c165077efc8f3a971534c50ea6a1a18b329ef4a66e897a7e3a1494565f" [[package]] name = "libc" -version = "0.2.183" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libredox" @@ -1421,6 +1629,12 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + [[package]] name = "mime" version = "0.3.17" @@ -1448,6 +1662,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1457,12 +1683,81 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1496,6 +1791,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "parking" version = "2.2.1" @@ -1631,6 +1932,15 @@ dependencies = [ "syn", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.11+spec-1.1.0", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1640,6 +1950,20 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "process-wrap" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e842efad9119158434d193c6682e2ebee4b44d6ad801d7b349623b3f57cdf55" +dependencies = [ + "futures", + "indexmap", + "nix", + "tokio", + "tracing", + "windows", +] + [[package]] name = "quinn" version = "0.11.9" @@ -1795,6 +2119,23 @@ dependencies = [ "syn", ] +[[package]] +name = "referencing" +version = "0.49.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6efa2154ea6f5ce0fdecdd2a8d18f2fa1a39a8fbba91564f555a592e4dce8278" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.17.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.12.3" @@ -1809,9 +2150,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -1824,13 +2165,22 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "relative-path" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca40a312222d8ba74837cb474edef44b37f561da5f773981007a10bbaa992b0" +dependencies = [ + "serde", +] + [[package]] name = "reqwest" version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -1870,7 +2220,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", @@ -1925,19 +2275,44 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67d69668de0b0ccd9cc435f700f3b39a7861863cf37a15e1f304ea78688a4826" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", + "chrono", + "futures", + "pastey", + "pin-project-lite", + "rmcp-macros 1.5.0", + "schemars", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcd2b6dd3b18129368955f32661a7718969e8c152c7d8866434c09cf15a512e0" +dependencies = [ + "async-trait", + "base64 0.23.0", "chrono", "futures", "pastey", "pin-project-lite", - "rmcp-macros", + "process-wrap", + "rmcp-macros 3.0.0", "schemars", "serde", "serde_json", "thiserror 2.0.18", "tokio", + "tokio-stream", "tokio-util", "tracing", + "uuid", ] [[package]] @@ -1953,6 +2328,67 @@ dependencies = [ "syn", ] +[[package]] +name = "rmcp-macros" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1aa4b9345795260a43fc23d6d05e096407c8b953903f673af7c4404b49fb2d6" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn", +] + +[[package]] +name = "rquickjs" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e04e4eedfb060b503b5f0a2644abb890b0b3620d3fb674f9455f230014964e4" +dependencies = [ + "rquickjs-core", + "rquickjs-macro", +] + +[[package]] +name = "rquickjs-core" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e4f499ac5b943d97ee6dbc44f23c2c10426f420f7d2f1793d6318911b6608c" +dependencies = [ + "async-lock", + "hashbrown 0.17.1", + "relative-path", + "rquickjs-sys", +] + +[[package]] +name = "rquickjs-macro" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbcc8219b70ee2faa08d5339f47f15741cba2ce0cb9640e8495f2ab51293f50" +dependencies = [ + "convert_case 0.11.0", + "fnv", + "ident_case", + "indexmap", + "proc-macro-crate", + "proc-macro2", + "quote", + "rquickjs-core", + "syn", +] + +[[package]] +name = "rquickjs-sys" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13ac243b86a74120814ef7e9e30ad5a2c1199b7b9963b1cf7c84e4cdc1cad99" +dependencies = [ + "cc", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -2081,7 +2517,7 @@ dependencies = [ "futures", "futures-concurrency", "jsonrpcmsg", - "rmcp", + "rmcp 1.5.0", "rustc-hash", "sacp-derive", "schemars", @@ -2440,8 +2876,11 @@ dependencies = [ "flate2", "home", "indoc", + "jsonschema", "regex", "reqwest 0.12.28", + "rmcp 3.0.0", + "rquickjs", "sacp", "semver", "serde", @@ -2719,6 +3158,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -2935,6 +3385,12 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -3006,6 +3462,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "valuable" version = "0.1.1" @@ -3018,6 +3484,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "walkdir" version = "2.5.0" @@ -3201,6 +3673,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -3214,6 +3707,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -3242,6 +3746,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-registry" version = "0.6.1" @@ -3355,6 +3869,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" diff --git a/Cargo.toml b/Cargo.toml index 8e080599..47b519fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,9 @@ dialoguer = "0.12.0" toml_edit = "0.25.11" url = "2.5.8" symposium-install = { version = "0.1.0", path = "symposium-install", features = ["clap"] } +rquickjs = { version = "0.12.2", features = ["futures", "macro"] } +rmcp = { version = "3", features = ["server", "client", "transport-io", "transport-child-process", "macros"] } +jsonschema = { version = "0.49.9", default-features = false } [dev-dependencies] diff --git a/examples/mock-mcp-server.rs b/examples/mock-mcp-server.rs new file mode 100644 index 00000000..a57e41f3 --- /dev/null +++ b/examples/mock-mcp-server.rs @@ -0,0 +1,263 @@ +//! A configurable MCP server, for exercising the meta-server against +//! behavior real servers exhibit but cannot be asked to reproduce on demand. +//! +//! Driven entirely by a JSON config, so one binary plays every role a test +//! needs — a slow start, a hang, a crash partway through, a response too big +//! to pass on, an older protocol version: +//! +//! ```json +//! { +//! "name": "sqlx", +//! "startup_delay_ms": 0, +//! "fail_startup_times": 0, +//! "tools": [ +//! { "name": "query", "inputSchema": { "type": "object" }, +//! "behavior": { "kind": "echo" } }, +//! { "name": "slow", "behavior": { "kind": "hang" } }, +//! { "name": "boom", "behavior": { "kind": "crash_after", "calls": 1 } } +//! ] +//! } +//! ``` +//! +//! An `examples/` target rather than a `[[bin]]`: `cargo test` builds it, but +//! it is not installed alongside `cargo-agents`. +//! +//! `fail_startup_times` counts across runs via a sibling file, so a restart +//! policy can be tested without the harness having to rewrite the config. + +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use rmcp::handler::server::ServerHandler; +use rmcp::model::*; +use rmcp::service::RequestContext; +use rmcp::{ErrorData as McpError, RoleServer, ServiceExt}; +use serde::Deserialize; +use serde_json::{Map, Value, json}; + +#[derive(Debug, Deserialize)] +struct Config { + #[serde(default = "default_name")] + name: String, + /// Delay before serving, for exercising a startup timeout. + #[serde(default)] + startup_delay_ms: u64, + /// Exit during startup this many times before succeeding, for exercising + /// restart policy. + #[serde(default)] + fail_startup_times: usize, + /// Protocol version to report. Older versions predate structured output. + #[serde(default)] + protocol_version: Option, + /// Appended to on every start, so a test can assert a server did *not* + /// start. + #[serde(default)] + startup_log: Option, + #[serde(default)] + tools: Vec, +} + +fn default_name() -> String { + "mock".to_string() +} + +#[derive(Debug, Clone, Deserialize)] +struct ToolConfig { + name: String, + #[serde(default)] + description: Option, + #[serde(rename = "inputSchema", default)] + input_schema: Option, + #[serde(rename = "outputSchema", default)] + output_schema: Option, + #[serde(default)] + behavior: Behavior, +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum Behavior { + /// Return the arguments as structured content. + #[default] + Echo, + /// Return a fixed string. + Text { text: String }, + /// Return a tool error, as distinct from a protocol error. + Error { message: String }, + /// Never answer, for exercising a per-call timeout. + Hang, + /// Answer normally, then exit the process once `calls` have been served. + CrashAfter { calls: usize }, + /// Return a payload of the given size, for exercising a result cap. + Bloat { bytes: usize }, + /// Answer after a delay. + Delay { ms: u64, text: String }, +} + +#[derive(Clone)] +struct Mock { + config: Arc, + calls: Arc, +} + +impl ServerHandler for Mock { + fn get_info(&self) -> ServerInfo { + let mut info = ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(Implementation::new(self.config.name.clone(), "0.0.0")); + if let Some(version) = &self.config.protocol_version { + // The field is private and there is no constructor from a string; + // its `Deserialize` is the supported way in. + if let Ok(parsed) = serde_json::from_value(json!(version)) { + info = info.with_protocol_version(parsed); + } + } + info + } + + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + let tools = self + .config + .tools + .iter() + .map(|tool| { + let schema = tool + .input_schema + .clone() + .unwrap_or_else(|| json!({"type": "object"})); + let mut definition = Tool::new( + tool.name.clone(), + tool.description.clone().unwrap_or_default(), + as_object(schema), + ); + if let Some(output) = tool.output_schema.clone() { + definition = definition.with_raw_output_schema(Arc::new(as_object(output))); + } + definition + }) + .collect(); + Ok(ListToolsResult::with_all_items(tools)) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + _context: RequestContext, + ) -> Result { + let Some(tool) = self + .config + .tools + .iter() + .find(|t| t.name == request.name.as_ref()) + else { + return Err(McpError::invalid_params( + format!("no such tool: {}", request.name), + None, + )); + }; + + let args = request.arguments.map(Value::Object).unwrap_or(Value::Null); + let served = self.calls.fetch_add(1, Ordering::SeqCst) + 1; + + match &tool.behavior { + Behavior::Echo => Ok(CallToolResult::structured(args).into()), + Behavior::Text { text } => { + Ok(CallToolResult::success(vec![ContentBlock::text(text)]).into()) + } + Behavior::Error { message } => { + Ok(CallToolResult::error(vec![ContentBlock::text(message)]).into()) + } + Behavior::Hang => { + // Outlives any plausible test timeout without pinning a CPU. + std::future::pending::<()>().await; + unreachable!() + } + Behavior::Delay { ms, text } => { + tokio::time::sleep(std::time::Duration::from_millis(*ms)).await; + Ok(CallToolResult::success(vec![ContentBlock::text(text)]).into()) + } + Behavior::Bloat { bytes } => { + Ok(CallToolResult::structured(json!({ "blob": "x".repeat(*bytes) })).into()) + } + Behavior::CrashAfter { calls } => { + if served > *calls { + // Abrupt, as a real server dying mid-session would be. + std::process::exit(70); + } + Ok(CallToolResult::structured(json!({ "served": served })).into()) + } + } + } +} + +fn as_object(value: Value) -> Map { + match value { + Value::Object(map) => map, + _ => Map::new(), + } +} + +/// Count startup failures across runs. +/// +/// A restart policy can only be tested if the server fails a fixed number of +/// times and then recovers, which needs state the process itself does not +/// keep. +fn should_fail_startup(config_path: &PathBuf, budget: usize) -> bool { + if budget == 0 { + return false; + } + let counter = config_path.with_extension("startups"); + let so_far: usize = std::fs::read_to_string(&counter) + .ok() + .and_then(|t| t.trim().parse().ok()) + .unwrap_or(0); + let _ = std::fs::write(&counter, (so_far + 1).to_string()); + so_far < budget +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let mut args = std::env::args().skip(1); + let mut config_path: Option = None; + while let Some(arg) = args.next() { + if arg == "--config" { + config_path = args.next().map(PathBuf::from); + } + } + let Some(config_path) = config_path else { + anyhow::bail!("usage: mock-mcp-server --config "); + }; + + let config: Config = serde_json::from_str(&std::fs::read_to_string(&config_path)?)?; + + if let Some(path) = &config.startup_log { + use std::io::Write; + if let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + { + let _ = writeln!(file, "{}", config.name); + } + } + + if should_fail_startup(&config_path, config.fail_startup_times) { + eprintln!("mock-mcp-server: failing startup on purpose"); + std::process::exit(1); + } + if config.startup_delay_ms > 0 { + tokio::time::sleep(std::time::Duration::from_millis(config.startup_delay_ms)).await; + } + + let mock = Mock { + config: Arc::new(config), + calls: Arc::new(AtomicUsize::new(0)), + }; + let service = mock.serve(rmcp::transport::io::stdio()).await?; + service.waiting().await?; + Ok(()) +} diff --git a/md/design/module-structure.md b/md/design/module-structure.md index d2590a0b..d7e339a1 100644 --- a/md/design/module-structure.md +++ b/md/design/module-structure.md @@ -30,6 +30,8 @@ Two entry points: `sync(sym, cwd)` for standalone CLI use (creates its own `Work `sync` takes an `UpdateLevel` that it threads into skill resolution (`skills::collect_skills`), controlling how aggressively `source.git` skill groups are re-fetched. Callers choose: the auto-sync path passes `Check` on `SessionStart` (refresh) and `None` otherwise (debounced); the binary's global `--update` flag feeds manual `cargo agents sync`. +MCP registration reads the same active plugin set skills do, so a crate-sourced plugin's servers are in scope exactly like a registry plugin's. What gets *written* depends on `[mcp] enabled` (default on): on, a single `symposium` entry (`META_SERVER_NAME` / `meta_server_entry`) naming `cargo-agents mcp-serve`, with the individual plugin servers left to the [meta-server](#mcp--mcp-meta-server) to start on demand; off, the plugin server entries themselves. Whichever set is not in use is unregistered in the same pass (`stale_names`), so switching the flag does not leave entries behind, and the reap list covers both so a dropped agent is cleaned either way. The entry names `cargo-agents` as a command rather than an absolute path, matching how hooks are registered — an absolute path breaks the moment the binary is reinstalled elsewhere. + ### `plugins.rs` — plugin registry Loads plugin manifests from the configured registries and parses them into `Plugin` structs. Loading goes through the [package-manager layer](#pm--package-managers): `load_registry` asks each **trusted** `PmRegistry` instance (the configured registries — the cargo transport is not a trust root) for its plugins via `active_plugins`. A `PathPm` interprets each entry through `load_entry` as either a `SYMPOSIUM.toml` manifest plugin or a bare `SKILL.md` synthesized into a default plugin (`load_standalone_skill_plugin`); dependency-embedded crate plugins never load here. Refreshing a git registry's content is the `GitPm`'s `refresh` operation, driven by `ensure_registries` (startup) and `sync_registries` (`plugin sync`). `scan_source_dir` remains as the offline form used by the `plugin validate` CLI, which points at an arbitrary directory rather than a configured registry; it walks the same [layout](#pm--package-managers) rules and synthesizes bare skills the same way. @@ -148,6 +150,20 @@ Handles the hook pipeline: parse agent wire-format input → auto-sync → built Builtin dispatch currently only acts on `SessionStart`, where `handle_session_start` composes three independently-computed `additionalContext` fragments: a `discovery_hint` (suggests `cargo agents --help` when the workspace exposes applicable plugin subcommands, reusing `subcommand_dispatch::applicable_subcommands`), a `consent_hint` (names the dependency plugins awaiting consent, via `discovery::pending_candidates` — a hook must never block on stdin, so the candidates are reported as context pointing at `cargo agents sync` / `cargo agents use` rather than asked about), and an `update_nudge` (the throttled self-update warning); only the nudge is gated behind the update-check throttle. The plugin dispatch path matches plugin `Hook`s against the event over the active plugin set (`skills::active_plugins`, so crate-sourced hooks fire too), selects the best format for each plugin (native match > symposium > single-other-agent fallback), builds a `ResolvedHook` per match (looking up the named installations on the plugin), then for each `ResolvedHook`: acquires its `requirements` (best-effort), runs `install_commands` after the source step, picks a `Runnable` from (hook-or-install) `executable`/`script`, and spawns it (binary directly for `Exec`, via `sh ` for `Script`). Input is delivered in the selected format; output is converted back to the agent's wire format before returning. +### `mcp/` — MCP meta-server + +One MCP server is registered with each agent (see [`sync.rs`](#syncrs--synchronization-command)); it exposes two tools — `list_tools` and `execute` — rather than every plugin server's tools directly. The agent writes a JavaScript snippet against the backing servers' tools and gets one result, instead of the whole workspace's tool schemas being loaded into its context up front. See the [MCP meta-server RFD](../rfds/mcp-meta-server/README.md) for the design rationale; every file carries a module doc covering its own reasoning. + +- `server.rs` — the meta-server process. Two constraints shape it: stdout carries JSON-RPC (all reporting goes to stderr, which is why the binary forces `ReportMode::Verbose` for `mcp-serve`), and startup must have no side effects (clients probe a server by spawning a throwaway copy, so the binary also skips `ensure_registries` and the self-update re-exec for `mcp-serve`). +- `resolve.rs` — which backing servers the workspace makes available. `resolve` / `resolve_with_deps` build the predicate context and run the **active plugin set** (`plugins::active_plugins`, the same set skills and hooks use, so a crate-sourced plugin's servers are in scope), then filter each plugin's `applicable_mcp_entries` and reconcile each entry's author-declared timeouts against the user's `script-timeout-secs`. Nothing is started here — a `ResolvedServer` acquires its installation and produces a `SpawnSpec` on first use. `prewarm` is the `SessionStart` warm-up: `requirements` are acquired eagerly (declaring one *is* the author asking for a warm cache), while `installation` commands are only refreshed if already present, as hooks are. +- `catalog.rs` — what the tools look like to the model. `list_tools` answers with a one-line-per-tool index by default and full declarations only when asked, since a single mainstream server's schemas can fill a declaration budget alone. Descriptions need a live `tools/list`, so the first call starts the servers it needs; `refresh_if_stale` re-resolves when `Cargo.lock` moves under a session, carrying unchanged servers across still running. +- `supervisor.rs` — keeping one backing server available: lazy start, exponential restart backoff, and a restart count that clears after a long-lived connection. The tool *call* is never retried — a call that died mid-flight may already have had its effect. +- `client.rs` — the only protocol-shaped file; the rest of the module works in `serde_json::Value`. Distinguishes a tool failure (a successful response carrying an error flag) from a protocol failure, and unwraps the content envelope into the value a script wants. +- `sandbox.rs` / `console.rs` / `normalize.rs` — the QuickJS sandbox a script runs in (deny-by-construction: no filesystem, network, process or module loader is ever registered; two layers of deadline, since an interpreter interrupt cannot fire while a script awaits a host call), the bounded `console` capture, and the normalization that turns model-written TypeScript / fenced / statement-body source into something the engine can evaluate. +- `dispatch.rs` — how a script reaches a backing server. Namespaces are built through the object API rather than by generating JavaScript, so manifest-supplied server and tool names never reach a code position; a channel keeps the engine thread and the servers' I/O each on the runtime they belong to. +- `declarations.rs` / `schema_to_ts.rs` — rendering a server's tools as TypeScript. Each server is an object of methods (not a `declare namespace`), so a tool whose wire name is not a JS identifier can still be declared. Schema conversion never fails (an unrecognized construct becomes `unknown`, never `any`, so the model must narrow). A tool declaring an `outputSchema` renders that as its return type; one that does not stays `Promise`. `binding_table` is the single source of both declarations and runtime bindings, and `resolve_key` (used by `Catalog::call`) makes lookup tolerant of case and punctuation, so a model reaching for `createEntities` still lands `create_entities` — declared names match exactly first, an ambiguous fallback is refused rather than guessed, and nothing extra is declared. `testdata/` holds real-world tool dumps and expected `.d.ts` output, checked by `corpus_tests.rs`, whose `declarations_type_check` runs the generated declarations through real `tsc`. +- `validate.rs` — checking a result against the `outputSchema` its tool declared, called from `Catalog::call` after unwrapping (that is the value a script receives). A mismatch is **tagged, never fatal**, since answering with text where an object was declared is ordinary MCP and the value is usually still readable: `Catalog::call` returns a `CallOutcome { value, notice }`, the script gets the value unchanged, and `server.rs`'s call pump collects the notice — deduplicated, one tool being one fact — into the same `problems` list `execute` already reports back. The notice is one line (`.: result off-shape, treat as unknown`) rather than prose, because a correction costing more context than the type it corrects would defeat the design; the failure detail goes to the log. Remote `$ref`s are never resolved, so a third-party schema cannot trigger an outbound request, and an uncompilable schema is not checked at all. + ### `state.rs` — persistent state Manages `state.toml` in the config directory. Deserializes through `RawState` and validates into the runtime `State`. Tracks the semver of the binary that last touched the directory (for future migration hooks) and the timestamp of the last update check (to throttle crates.io queries to once per 24 hours). `ensure_current()` is called on startup to silently stamp the current version. `should_check_for_update()` / `record_update_check()` gate the auto-update flow. diff --git a/md/reference/plugin-definition.md b/md/reference/plugin-definition.md index 04f4b111..99440b5d 100644 --- a/md/reference/plugin-definition.md +++ b/md/reference/plugin-definition.md @@ -476,9 +476,24 @@ Results are cached by `(predicate_name, raw_arg)` for the duration of a single s ## `[[mcp_servers]]` -Each `[[mcp_servers]]` entry declares an MCP server that Symposium registers into the agent's configuration during `sync --agent`. +Each `[[mcp_servers]]` entry declares an MCP server that Symposium makes available to the agent when the entry's predicates hold. -There are multiple MCP transports: +Only `name` and one way of reaching the server are required. Every other field defaults, and an unknown key is rejected by name rather than being swallowed. + +### Fields common to every transport + +| Field | Type | Description | +|-------|------|-------------| +| `name` | string | Server name, as a script refers to it and as it appears in agent config. | +| `depends-on` | string or array | Which crates this server applies to. Optional if plugin has top-level `depends-on`. | +| `predicates` | array of strings | Predicates (`depends-on`, `shell`, `path_exists`, `env`, `workspace-member`, `not`, `any`, `all`) that must all hold for the server to apply. See [Predicates](./predicates.md). | +| `requirements` | array of strings | Names of `[[installations]]` entries to acquire before the server first starts. An entry carrying only `install_commands` warms a cache so a package-runner download does not land on the agent's first tool call. | +| `startup-timeout-secs` | integer | Ceiling on spawning this server and completing its handshake. Overrides the user's `[mcp]` default. | +| `tool-call-timeout-secs` | integer | Ceiling on a single tool call to this server. Overrides the user's `[mcp]` default. | +| `enabled-tools` | array of strings | Expose only these tools. An empty list exposes nothing, which is distinct from omitting the field. | +| `disabled-tools` | array of strings | Expose everything except these. Mutually exclusive with `enabled-tools`. | + +The timeouts are clamped against the user's `script-timeout-secs` at dispatch, so an entry can lower a ceiling but never raise the user's. ### Stdio @@ -487,66 +502,45 @@ There are multiple MCP transports: name = "my-server" command = "/usr/local/bin/my-server" args = ["--stdio"] -env = [] +env = { RUST_LOG = "debug" } +cwd = "crates/db" ``` | Field | Type | Description | |-------|------|-------------| -| `name` | string | Server name as it appears in the agent's MCP config. | -| `depends-on` | string or array | Which crates this server applies to. Optional if plugin has top-level `depends-on`. | -| `predicates` | array of strings | Predicates (`depends-on`, `shell`, `path_exists`, `env`, `workspace-member`, `not`, `any`, `all`) that must all hold for the server to register. See [Predicates](./predicates.md). | -| `command` | string | Path to the server binary. | -| `args` | array of strings | Arguments passed to the binary. | -| `env` | array of objects | Environment variables to set when launching the server. | +| `command` | string | Executable to run, resolved through `PATH` unless absolute. | +| `installation` | string | Name of an `[[installations]]` entry providing the executable, acquired before first use. Use instead of `command`, not alongside it. | +| `args` | array of strings | Arguments passed to the binary. Defaults to empty. | +| `env` | table | Environment for the child, as a `name = "value"` table. Defaults to empty. | +| `cwd` | string | Directory to run in, resolved against the workspace root. | -Stdio entries do not need a `type` field. +### HTTP and SSE -### HTTP +A `url` makes the entry remote. `transport` selects between the two remote transports and defaults to `http`; there is no `type` field. ```toml [[mcp_servers]] -type = "http" name = "my-server" url = "http://localhost:8080/mcp" -headers = [] -``` +headers = { Authorization = "Bearer token" } -| Field | Type | Description | -|-------|------|-------------| -| `type` | string | Must be `"http"`. | -| `name` | string | Server name as it appears in the agent's MCP config. | -| `depends-on` | string or array | Which crates this server applies to. Optional if plugin has top-level `depends-on`. | -| `url` | string | HTTP endpoint URL. | -| `headers` | array of objects | HTTP headers to set when making requests. | - -### SSE - -```toml [[mcp_servers]] -type = "sse" -name = "my-server" +name = "my-sse-server" url = "http://localhost:8080/sse" -headers = [] +transport = "sse" ``` | Field | Type | Description | |-------|------|-------------| -| `type` | string | Must be `"sse"`. | -| `name` | string | Server name as it appears in the agent's MCP config. | -| `depends-on` | string or array | Which crates this server applies to. Optional if plugin has top-level `depends-on`. | -| `url` | string | SSE endpoint URL. | -| `headers` | array of objects | HTTP headers to set when making requests. | +| `url` | string | The endpoint. Setting it rejects `args`, `env` and `cwd`, which apply only to a child process. | +| `transport` | string | `"http"` (the default) or `"sse"`. | +| `headers` | table | Headers to send, as a `name = "value"` table. Only valid with a `url`. | ### How registration works -During `cargo agents sync --agent`, each MCP server entry is written into the agent's config file in the format that agent expects. Registration is idempotent — existing entries with correct values are left untouched, stale entries are updated in place. - -When a user runs `cargo agents sync` (or the hook triggers it automatically), Symposium: +With the meta-server enabled (`[mcp] enabled`, the default), `sync` writes a single `symposium` entry into each agent's config instead of one entry per server. The agent sees two tools; the servers behind them are started on demand when a script calls one, and a server whose predicates do not hold is never started at all. Declaring a server is therefore all a plugin does — there is no per-agent MCP configuration to maintain. -1. Collects `[[mcp_servers]]` entries from all enabled plugins. -2. Writes each server into the agent's MCP configuration file. - -All supported agents have MCP server configuration. Symposium handles the format differences — you declare the server once and it works across agents. +Setting `[mcp] enabled = false` restores the direct path: each applicable entry is written into the agent's own MCP config, and Symposium handles the format differences. | Agent | Config location | Key | |-------|----------------|-----| @@ -558,6 +552,8 @@ All supported agents have MCP server configuration. Symposium handles the format | OpenCode | `opencode.json` | `mcp.` | | Goose | `~/.config/goose/config.yaml` | `extensions.` | +Either way registration is idempotent — existing entries with correct values are left untouched, and entries written by the configuration you switched away from are removed. + ## Example: full manifest ```toml @@ -583,7 +579,6 @@ command = { source = "local", command = "./scripts/check-widget.sh" } name = "widgetlib-mcp" command = "/usr/local/bin/widgetlib-mcp" args = ["--stdio"] -env = [] ``` ## Validation diff --git a/md/reference/predicates.md b/md/reference/predicates.md index 4e2e785b..c1e2d3a8 100644 --- a/md/reference/predicates.md +++ b/md/reference/predicates.md @@ -77,8 +77,6 @@ predicates = ["path_exists(.git)"] [[mcp_servers]] name = "tool" command = "/usr/local/bin/tool" -args = [] -env = [] predicates = ["path_exists(tool)"] ``` diff --git a/md/rfds/mcp-meta-server/README.md b/md/rfds/mcp-meta-server/README.md index 3955252d..329e0a0f 100644 --- a/md/rfds/mcp-meta-server/README.md +++ b/md/rfds/mcp-meta-server/README.md @@ -343,3 +343,99 @@ Re-resolve the workspace on `list_tools` when `Cargo.lock` mtime has changed sin - [ ] Track `Cargo.lock` mtime at startup - [ ] On `list_tools`, check mtime; if changed and auto-sync enabled, re-resolve - [ ] Integration test: modify fixture's `Cargo.lock` mid-session, verify index updates + + +# Implementation vs design + +Everything here came out of two things, surveying what shipping MCP servers actually emit, and using the feature. + +### The capability index moved from `list_tools` to `execute` + +As designed, the server inventory lives in `list_tools`'s description, so a model must call `list_tools` before it knows anything at all. It now lives in `execute`'s description instead. + +The inventory costs about twenty tokens per server and is what a model needs to decide whether to look further. Putting it on the tool it will actually call removes the discovery round trip entirely for a small workspace, which is the common case. + +### `list_tools` returns an index by default, and truncation was dropped + +The design truncates the index past a byte budget. Truncation is lossy and varies with the workspace, so the same question gets a different answer in different projects. + +Instead `list_tools` answers with a name-and-description index and returns full declarations only when asked, by naming servers or tools, or by an explicit detail level. An index is lossless and strictly smaller than a truncated dump. + +The sizing that forces this is that one mainstream server's `tools/list` runs about 49 KB. The extreme case is not many tools but one tool with a very long description, so the reference "sequential thinking" server is 4.6 KB for a single tool. + +### Tools are declared as an object, not a namespace + +The design shows `declare namespace sqlx { function query(...) }`. Shipped declarations use an object instead: `declare const sqlx: { query(...) }`. + +A TypeScript namespace cannot declare a member whose name is not a valid identifier, and hyphenated tool names are ordinary. An object type accepts quoted keys, so a hyphenated tool can be both declared and called. Each such tool is bound under both spellings. + +Declared names are the server's own, not camelCased into TypeScript style. Nothing in the captured corpus is camelCase (58 tools snake_case, 12 kebab-case), and snake_case is already a valid identifier, so camelCasing would rename the primary spelling of most tools and force the wire name to be carried as a second declared method just to stay visible — roughly doubling the listing for them, in a design whose point is context economy. It would also hide the name the server itself reports errors by. + +What that would have bought is had for nothing instead: a model trained on TypeScript may reach for `createEntities` when shown `create_entities`, so **lookup is tolerant of case and punctuation** while the declaration is untouched. `create_entities`, `createEntities` and `create-entities` all reach the same tool. A declared spelling always matches exactly first, so a server exposing two tools that differ only in punctuation keeps them distinct, and an ambiguous fallback is refused by name rather than guessed. Only tools in the visible set resolve, so a filtered tool cannot be summoned under another spelling. + +### Results are unwrapped, and a declared output schema becomes the return type + +The design's own example calls `.rows.map(...)` on a tool result, which only works if the host unwraps MCP's result envelope, but the design never says it does, while declaring return types `any`. + +Both halves changed. Results are unwrapped through an explicit ladder that checks the error flag _first_ (a server can report an error _and_ structured content, and checking content first swallows the error), and one popular Python framework's extra result wrapper is unwrapped too, since it survives a proxy hop. + +A tool that declares an `outputSchema` gets that schema as its return type; one that does not stays `Promise`. `unknown` forces a model to narrow the value rather than assume a shape; `any` invites the assumption. + +Typing the return raises a problem the design does not consider: nothing obliges a server to send what it declared. The unwrap ladder has four exits, and only the structured-content one can match an `outputSchema`. A server that answers with text lands on the text exit, and answering with text is not a defect, it is ordinary. So a declared type is a statement of intent, not a guarantee. + +The declared schema is therefore checked against the value at call time, and a mismatch is **tagged, never fatal**. Failing the call would discard a result the model can usually still read: `"count: 3"` is not the declared object but plainly carries the answer. Instead the value is passed through untouched, and one line rides back with it: + +``` +[memory.search_nodes: result off-shape, treat as unknown] +``` + +That is the one thing the model cannot work out for itself. Without it, it meets the breach as an `undefined` property, blames its own code, and typically spends another round trip probing the tool. + +The tag is deliberately not prose. A correction that costs more context than the type it corrects would defeat the purpose of the whole design, so which field was wrong is left out: the value accompanies the tag, so the model can read what it actually got, and the full failure list goes to the log where it costs nothing and is still there for whoever is debugging the server. The tag also rides the same channel as the other problems `execute` reports, so it never alters the value the script sees, and it is deduplicated: one tool answering off-shape in a loop is one fact about that tool. + +Two further limits: remote `$ref`s are never resolved, since fetching a URL a third-party schema names would turn a declaration into an outbound request; and a schema that cannot be compiled is not checked at all, matching the renderer's own rule that generation never fails whatever a server sends. + +Adoption is worth stating plainly, because an earlier draft of this section got it wrong. In the captured corpus 25 of 72 tools declare an output schema, and it is bimodal rather than rare: two of the six servers declare one on every tool, three declare none. Output schemas are also a recent protocol addition, so the share is more likely to grow than shrink. + +### The type-mapping table + +Measured against real captured tool lists, the table in this document sends **well under 2% of schema nodes** to `any`, for several servers, none at all. So motivation is narrower: one generator family, pydantic, systematically emits two constructs the table drops (an optional spelled as a union with null, and named type references), and everything else is a long tail. + +One construct absent from the table turned out to matter more than any listed: `additionalProperties: false` is the single most common thing in real schemas, and it must be **ignored**. A naive "unrecognized keyword becomes `unknown`" rule turns every ordinary object from three of the surveyed servers into `unknown`. + +### Model-written code normalization before running + +The model is handed TypeScript declarations and its output is run in a JavaScript engine that cannot parse a type annotation. Markdown fences, `export default`, a bare expression versus a statement body, and a named function that is never called are all ordinary things a model produces. + +Submitted source is normalized before evaluation, using the engine's own parser to decide between an expression and a statement body rather than guessing. + +### Timeouts + +A memory cap does not stop an infinite loop, and a timeout on the host does not stop a script that is spinning inside the interpreter. + +Two layers: an interrupt that fires while the interpreter runs and raises an error the script **cannot catch**, plus an outer deadline for a script blocked awaiting a host call, where the interpreter is idle and the interrupt can never fire. Neither alone is sufficient. Result size and console output are bounded too (an unbounded tool result lands directly in the agent's context, defeating this document's own thesis). +The default budget is 120 seconds rather than 30. A script that composes several calls against servers that each take seconds is the case the feature exists for. + +### A crashed server and duplicate name + +The design has a `Dead` state restarted on the next call, which crash-loops forever against a server that cannot start. There is now a restart cap with exponential backoff and a terminal failed state, plus a stability reset so a server that fails rarely is not permanently condemned. + +The design also resolves two plugins claiming one server name by first-registered-wins with a warning. That silently drops one plugin's entire server, and a warning on a stdio server's stderr is invisible. Both are now refused by name, as is a server taking a name the meta-server itself uses. Refusals appear in `list_tools` output rather than only in a log, so a real session showed that a refusal nobody can see is no better than the silent drop it replaced. + +### v1 is stdio only + +The design says the meta-server bridges whatever transport a backing server declares. HTTP and SSE entries parse but are refused with a reason. + +Two causes: the Rust SDK has no legacy SSE client, and SSE is still the default for URL-configured servers in most comparable projects; and forwarding credentials to a remote endpoint is an exfiltration surface not considered. + +### Symposium owns the plugin manifest shape + +Not in the original plan at all, but it came out of using the feature. Every entry needed empty `args` and `env` written out explicitly, and omitting either produced an error that named no field. + +The cause was one type doing three jobs: a wire format, a manifest schema, and a config-file schema. Symposium now defines the manifest shape itself giving optional fields default, environment is written as a table rather than a list of pairs, unknown keys are rejected by name, and a server can name an installation to acquire before it starts. + +### Registration flips last, not first + +The design's first step rewrites agent config to the single meta-server entry, before the meta-server exists. A release cut between that step and a working `mcp-serve` ships broken MCP for every user who takes it. + +It is now the last step, gated on a config flag, which also keeps the old behavior measurable against the new one without reverting code. diff --git a/src/agents/mcp_server_registration.rs b/src/agents/mcp_server_registration.rs index 7eee1f61..3c3a7c00 100644 --- a/src/agents/mcp_server_registration.rs +++ b/src/agents/mcp_server_registration.rs @@ -33,10 +33,19 @@ fn server_name(server: &McpServer) -> &str { } } +/// Render name/value pairs as a JSON object. +fn pairs_to_object<'a>(pairs: impl Iterator) -> serde_json::Value { + serde_json::Value::Object( + pairs + .map(|(name, value)| (name.clone(), serde_json::Value::String(value.clone()))) + .collect(), + ) +} + /// Convert an McpServer to the JSON value agents expect in their config. /// -/// Stdio: `{"command": "...", "args": [...], "env": [...]}` -/// Http/Sse: `{"url": "...", "headers": [...]}` +/// Stdio: `{"command": "...", "args": [...], "env": {...}}` +/// Http/Sse: `{"url": "...", "headers": {...}}` /// /// `env` and `headers` are omitted when empty. fn server_to_json(server: &McpServer) -> serde_json::Value { @@ -47,21 +56,24 @@ fn server_to_json(server: &McpServer) -> serde_json::Value { "args": s.args, }); if !s.env.is_empty() { - v["env"] = serde_json::to_value(&s.env).unwrap(); + // A map, not ACP's `[{name, value}]`. Every MCP client reads + // `env` as an object, so serializing the wire type directly + // produced config none of them could use. + v["env"] = pairs_to_object(s.env.iter().map(|e| (&e.name, &e.value))); } v } McpServer::Http(s) => { let mut v = json!({ "url": s.url }); if !s.headers.is_empty() { - v["headers"] = serde_json::to_value(&s.headers).unwrap(); + v["headers"] = pairs_to_object(s.headers.iter().map(|h| (&h.name, &h.value))); } v } McpServer::Sse(s) => { let mut v = json!({ "url": s.url }); if !s.headers.is_empty() { - v["headers"] = serde_json::to_value(&s.headers).unwrap(); + v["headers"] = pairs_to_object(s.headers.iter().map(|h| (&h.name, &h.value))); } v } @@ -551,6 +563,29 @@ mod tests { vec!["symposium"] } + /// Every MCP client reads `env` as an object. Serializing ACP's + /// `Vec` directly produced `[{"name":..,"value":..}]`, which + /// none of them understand. Unobservable while the only registered entry + /// carries no env, hence the direct test. + #[test] + fn env_and_headers_are_written_as_objects() { + let stdio = McpServer::Stdio( + McpServerStdio::new("s", "/bin/true") + .env(vec![sacp::schema::EnvVariable::new("API_KEY", "x")]), + ); + assert_eq!(server_to_json(&stdio)["env"], json!({"API_KEY": "x"})); + + let http = McpServer::Http( + sacp::schema::McpServerHttp::new("s", "http://localhost/mcp").headers(vec![ + sacp::schema::HttpHeader::new("Authorization", "Bearer x"), + ]), + ); + assert_eq!( + server_to_json(&http)["headers"], + json!({"Authorization": "Bearer x"}) + ); + } + // -- Claude MCP (also covers Gemini and Kiro via delegation) -- #[test] diff --git a/src/bin/cargo-agents.rs b/src/bin/cargo-agents.rs index cab3773d..4e2af1aa 100644 --- a/src/bin/cargo-agents.rs +++ b/src/bin/cargo-agents.rs @@ -54,7 +54,19 @@ async fn main() -> ExitCode { // Always install the report layer. Mode determines output format: // --json → accumulate JSON array; -v → stderr trace; default → stdout. - let (mode, level) = if cli.json { + // `mcp-serve` owns stdout for JSON-RPC. Reporting there would corrupt the + // stream, so it goes to stderr whatever the flags say. + let is_mcp_serve = matches!(cli.command, Some(Commands::McpServe)); + let (mode, level) = if is_mcp_serve { + ( + report::ReportMode::Verbose, + if cli.verbose { + tracing::Level::DEBUG + } else { + tracing::Level::INFO + }, + ) + } else if cli.json { let level = if cli.verbose { tracing::Level::DEBUG } else { @@ -86,6 +98,7 @@ async fn main() -> ExitCode { Some(Commands::Hook { agent, event }) => { tracing::debug!(?agent, ?event, "cargo agents hook"); } + Some(Commands::McpServe) => tracing::debug!("cargo agents mcp-serve"), Some(Commands::SelfUpdate) => tracing::info!("cargo agents self-update"), Some(Commands::CrateInfo { name, version }) => { tracing::debug!(%name, version = ?version, "cargo agents crate-info"); @@ -105,7 +118,7 @@ async fn main() -> ExitCode { // Hook commands are quiet by default (they're invoked by the agent, not the user). // JSON mode also suppresses human output (only JSON goes to stdout). let is_hook = matches!(cli.command, Some(Commands::Hook { .. })); - let out = if cli.quiet || is_hook || cli.json { + let out = if cli.quiet || is_hook || cli.json || is_mcp_serve { Output::quiet() } else { Output::normal() @@ -122,17 +135,23 @@ async fn main() -> ExitCode { } _ => cli.update, }; - plugins::ensure_registries(&sym, source_update).await; + // Skipped for `mcp-serve`: a client may spawn a throwaway copy to probe + // the server before the real session, so startup must not fetch or write. + if !is_mcp_serve { + plugins::ensure_registries(&sym, source_update).await; + } // Auto-update = "on": check for updates and re-exec if a new binary was // installed. Skipped for self-update (which always checks explicitly) // and for hooks (session-start injects the warn nudge into hook output; // the "on" re-exec for hooks is handled here). - if !matches!(cli.command, Some(Commands::SelfUpdate)) && !is_hook { + // Re-exec during an MCP session would drop the client's connection. + if !matches!(cli.command, Some(Commands::SelfUpdate)) && !is_hook && !is_mcp_serve { if self_update::maybe_check_for_update(&sym, &out).await { self_update::re_exec(); } } else if is_hook + && !is_mcp_serve && sym.config.auto_update == config::AutoUpdate::On && self_update::maybe_check_for_update(&sym, &Output::quiet()).await { @@ -143,6 +162,47 @@ async fn main() -> ExitCode { // Commands that need direct I/O (stdin/stdout) stay in the binary Some(Commands::Hook { agent, event }) => hook::run(&sym, agent, event).await, + Some(Commands::McpServe) => { + let resolution = symposium::mcp::resolve::resolve(&sym, &cwd).await; + for rejection in &resolution.rejected { + tracing::warn!( + server = %rejection.server, + "skipping mcp server: {}", + rejection.reason + ); + } + let catalog = std::sync::Arc::new(symposium::mcp::catalog::Catalog::new( + std::sync::Arc::new(sym.clone()), + resolution, + symposium::mcp::supervisor::RestartPolicy { + max_restarts: sym.config.mcp.max_server_restarts, + stable_reset: std::time::Duration::from_secs( + sym.config.mcp.restart_stable_reset_secs, + ), + shutdown_grace: std::time::Duration::from_secs( + sym.config.mcp.shutdown_grace_secs, + ), + ..Default::default() + }, + sym.config.mcp.read_only, + cwd.clone(), + )); + let limits = symposium::mcp::sandbox::Limits { + timeout: std::time::Duration::from_secs(sym.config.mcp.script_timeout_secs), + memory_bytes: (sym.config.mcp.script_memory_limit_mb as usize) << 20, + stack_bytes: (sym.config.mcp.script_stack_limit_kb as usize) << 10, + max_result_bytes: sym.config.mcp.max_result_bytes, + max_console_bytes: sym.config.mcp.max_console_bytes, + }; + match symposium::mcp::server::serve(catalog, limits).await { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("Error: {err:#}"); + ExitCode::FAILURE + } + } + } + Some(Commands::Plugin { command }) => { let code = handle_plugin_command(&sym, command).await; let events = report_handle.drain(); diff --git a/src/cli.rs b/src/cli.rs index 4868a998..71a7c793 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -113,6 +113,10 @@ pub enum Commands { event: hook::HookEvent, }, + /// MCP server entry point invoked by your agent (internal) + #[command(hide = true, name = "mcp-serve")] + McpServe, + /// Manage plugins Plugin { #[command(subcommand)] @@ -227,6 +231,11 @@ pub async fn run( } match cmd { + // Served by the binary, which owns stdio. + Commands::McpServe => Err(anyhow::anyhow!( + "mcp-serve must be run from the command line, not through the library" + )), + Commands::Init { agents, remove_agents, diff --git a/src/config.rs b/src/config.rs index 6e743b63..cef837e3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -93,6 +93,10 @@ pub struct Config { #[serde(default, skip_serializing_if = "TelemetryConfig::is_default")] pub telemetry: TelemetryConfig, + /// MCP meta-server settings. + #[serde(default, skip_serializing_if = "McpConfig::is_default")] + pub mcp: McpConfig, + /// Which discovered plugins the user has consented to. #[serde(default, skip_serializing_if = "PluginsConfig::is_default")] pub plugins: PluginsConfig, @@ -270,6 +274,121 @@ impl Default for LoggingConfig { } } +/// Settings for the MCP meta-server (`cargo agents mcp-serve`). +/// +/// Two groups of knobs with different owners. The sandbox limits +/// (`script-*`, `max-*`) protect the user's session from a runaway +/// agent-authored script, so a plugin may not raise its own ceiling. The +/// server timings (`server-startup-timeout-secs`, `tool-call-timeout-secs`) +/// describe a backing server, so plugins may override those per entry. +/// +/// `#[serde(default)]` on the container means every missing key falls back to +/// the value in [`McpConfig::default`], which is the single source of truth. +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +#[serde(default, deny_unknown_fields)] +pub struct McpConfig { + /// Register the meta-server and serve `mcp-serve`. + pub enabled: bool, + + /// Wall-clock ceiling for one `execute` call. + /// + /// Bounds everything inside it, so it must exceed + /// `tool-call-timeout-secs` or that knob can never fire. + #[serde(rename = "script-timeout-secs")] + pub script_timeout_secs: u64, + + /// Memory ceiling for the JS runtime. + #[serde(rename = "script-memory-limit-mb")] + pub script_memory_limit_mb: u64, + + /// Stack ceiling for the JS runtime. The engine thread's own stack is + /// sized above this, so deep recursion throws rather than segfaulting. + #[serde(rename = "script-stack-limit-kb")] + pub script_stack_limit_kb: u64, + + /// Maximum backing-server tool calls one `execute` may make. + #[serde(rename = "max-tool-calls")] + pub max_tool_calls: u32, + + /// Ceiling on a serialized `execute` return value. Oversized results are + /// truncated with a marker rather than rejected, so a script that already + /// performed side effects does not lose its work. + #[serde(rename = "max-result-bytes")] + pub max_result_bytes: usize, + + /// Ceiling on captured `console` output for one `execute`. + #[serde(rename = "max-console-bytes")] + pub max_console_bytes: usize, + + /// Ceiling on spawning a backing server and completing its handshake. + #[serde(rename = "server-startup-timeout-secs")] + pub server_startup_timeout_secs: u64, + + /// Ceiling on a single backing-server tool call. + #[serde(rename = "tool-call-timeout-secs")] + pub tool_call_timeout_secs: u64, + + /// Restart attempts before a backing server is marked permanently failed. + #[serde(rename = "max-server-restarts")] + pub max_server_restarts: u32, + + /// How long a backing server must stay connected before its restart + /// counter resets. Without this a server that dies rarely still exhausts + /// its budget eventually. + #[serde(rename = "restart-stable-reset-secs")] + pub restart_stable_reset_secs: u64, + + /// How long a backing server gets to exit gracefully before its process + /// group is killed. + #[serde(rename = "shutdown-grace-secs")] + pub shutdown_grace_secs: u64, + + /// Expose only tools annotated `readOnlyHint`, and reject the rest at + /// dispatch. The annotation is self-declared by the backing server, so + /// this is a guardrail against agent mistakes, not a security boundary. + #[serde(rename = "read-only")] + pub read_only: bool, +} + +impl Default for McpConfig { + fn default() -> Self { + Self { + enabled: true, + script_timeout_secs: 120, + script_memory_limit_mb: 64, + script_stack_limit_kb: 1024, + max_tool_calls: 100, + max_result_bytes: 32 * 1024, + max_console_bytes: 8 * 1024, + server_startup_timeout_secs: 30, + tool_call_timeout_secs: 60, + max_server_restarts: 5, + restart_stable_reset_secs: 300, + shutdown_grace_secs: 5, + read_only: false, + } + } +} + +impl McpConfig { + fn is_default(&self) -> bool { + *self == McpConfig::default() + } + + /// The script deadline bounds every tool call made inside it, so a + /// per-call timeout at or above it can never fire. + fn validate(&self) -> anyhow::Result<()> { + if self.tool_call_timeout_secs >= self.script_timeout_secs { + anyhow::bail!( + "[mcp] tool-call-timeout-secs ({}) must be less than script-timeout-secs ({})", + self.tool_call_timeout_secs, + self.script_timeout_secs + ); + } + Ok(()) + } +} + impl Default for Config { fn default() -> Self { Self { @@ -279,6 +398,7 @@ impl Default for Config { hook_scope: HookScope::default(), auto_update: AutoUpdate::default(), telemetry: TelemetryConfig::default(), + mcp: McpConfig::default(), plugins: PluginsConfig::default(), agents: Vec::new(), logging: LoggingConfig::default(), @@ -304,6 +424,8 @@ struct RawConfig { #[serde(default)] telemetry: TelemetryConfig, #[serde(default)] + mcp: McpConfig, + #[serde(default)] plugins: PluginsConfig, #[serde(default, rename = "agent")] agents: Vec, @@ -322,20 +444,22 @@ impl Default for RawConfig { } impl RawConfig { - fn validate(self) -> Config { - Config { + fn validate(self) -> anyhow::Result { + self.mcp.validate()?; + Ok(Config { auto_sync: self.auto_sync, agents_syncing: self.agents_syncing, sync_debounce_secs: self.sync_debounce_secs, hook_scope: self.hook_scope, auto_update: self.auto_update, telemetry: self.telemetry, + mcp: self.mcp, plugins: self.plugins, agents: self.agents, logging: self.logging, defaults: self.defaults, registries: self.registries, - } + }) } } @@ -348,6 +472,7 @@ impl From for RawConfig { hook_scope: config.hook_scope, auto_update: config.auto_update, telemetry: config.telemetry, + mcp: config.mcp, plugins: config.plugins, agents: config.agents, logging: config.logging, @@ -726,15 +851,19 @@ fn resolve_logs_dir(config_dir: &Path) -> PathBuf { /// Load config from a config directory. fn load_config_from(config_dir: &Path) -> Config { let path = config_dir.join("config.toml"); - match fs::read_to_string(&path) { - Ok(contents) => toml::from_str::(&contents) - .unwrap_or_else(|e| { - eprintln!("warning: failed to parse {}: {e}", path.display()); - RawConfig::default() - }) - .validate(), - Err(_) => Config::default(), - } + let Ok(contents) = fs::read_to_string(&path) else { + return Config::default(); + }; + toml::from_str::(&contents) + .unwrap_or_else(|e| { + eprintln!("warning: failed to parse {}: {e}", path.display()); + RawConfig::default() + }) + .validate() + .unwrap_or_else(|e| { + eprintln!("warning: invalid config in {}: {e}", path.display()); + Config::default() + }) } fn default_true() -> bool { @@ -755,7 +884,18 @@ mod tests { use indoc::indoc; fn parse_config(toml: &str) -> Config { - toml::from_str::(toml).unwrap().validate() + toml::from_str::(toml) + .unwrap() + .validate() + .unwrap() + } + + fn validate_err(toml: &str) -> String { + toml::from_str::(toml) + .unwrap() + .validate() + .expect_err("config should have been rejected") + .to_string() } #[test] @@ -927,6 +1067,126 @@ mod tests { ); } + #[test] + fn parse_mcp_defaults() { + let config = parse_config(""); + let mcp = &config.mcp; + assert!(mcp.enabled); + assert!(!mcp.read_only); + assert_eq!(mcp.script_timeout_secs, 120); + assert_eq!(mcp.tool_call_timeout_secs, 60); + assert_eq!(mcp.server_startup_timeout_secs, 30); + assert_eq!(mcp.max_result_bytes, 32 * 1024); + assert_eq!(mcp.max_server_restarts, 5); + assert_eq!(mcp.shutdown_grace_secs, 5); + } + + /// An `[mcp]` table sets only the keys it names; the rest keep their defaults. + #[test] + fn parse_mcp_partial_table_keeps_other_defaults() { + let config = parse_config(indoc! {" + [mcp] + read-only = true + max-tool-calls = 7 + "}); + assert!(config.mcp.read_only); + assert_eq!(config.mcp.max_tool_calls, 7); + assert_eq!( + config.mcp.script_timeout_secs, + McpConfig::default().script_timeout_secs, + "unnamed keys should keep their defaults, got: {:#?}", + config.mcp + ); + } + + #[test] + fn parse_mcp_disabled() { + let config = parse_config(indoc! {" + [mcp] + enabled = false + "}); + assert!(!config.mcp.enabled); + } + + /// A misspelled key is rejected rather than silently ignored. + #[test] + fn parse_mcp_rejects_unknown_key() { + let err = toml::from_str::(indoc! {" + [mcp] + script-timout-secs = 30 + "}) + .expect_err("misspelled key should not parse"); + assert!( + err.to_string().contains("script-timout-secs"), + "error should name the offending key, got: {err}" + ); + } + + /// A per-call timeout at or above the script deadline could never fire, + /// so it is rejected rather than silently ignored. + #[test] + fn mcp_rejects_tool_call_timeout_at_or_above_script_timeout() { + for (call, script) in [(60u64, 60u64), (120, 30)] { + let err = validate_err(&format!( + "[mcp]\nscript-timeout-secs = {script}\ntool-call-timeout-secs = {call}\n" + )); + assert!( + err.contains("tool-call-timeout-secs") && err.contains("script-timeout-secs"), + "error should name both knobs for call={call} script={script}, got: {err}" + ); + } + } + + #[test] + fn mcp_accepts_tool_call_timeout_below_script_timeout() { + let config = parse_config(indoc! {" + [mcp] + script-timeout-secs = 30 + tool-call-timeout-secs = 29 + "}); + assert_eq!(config.mcp.tool_call_timeout_secs, 29); + } + + /// Raising only the per-call timeout must not silently pass by sitting + /// under the *default* script deadline the user never touched. + #[test] + fn mcp_rejects_raised_tool_call_timeout_against_default_script_timeout() { + let err = validate_err(indoc! {" + [mcp] + tool-call-timeout-secs = 600 + "}); + assert!( + err.contains("600"), + "error should quote the offending value, got: {err}" + ); + } + + #[test] + fn default_mcp_is_omitted_from_serialized_config() { + let config = Config::default(); + let serialized = toml::to_string_pretty(&config).unwrap(); + assert!( + !serialized.contains("[mcp]"), + "default mcp settings should not be written to config.toml: {serialized}" + ); + } + + #[test] + fn customized_mcp_is_written_to_serialized_config() { + let config = Config { + mcp: McpConfig { + read_only: true, + ..McpConfig::default() + }, + ..Config::default() + }; + let serialized = toml::to_string_pretty(&config).unwrap(); + assert!( + serialized.contains("read-only = true"), + "customized mcp settings should round-trip: {serialized}" + ); + } + #[test] fn parse_agents_syncing_disabled() { let config = parse_config(indoc! {" diff --git a/src/hook.rs b/src/hook.rs index 006ea60c..239ca9f8 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -251,6 +251,7 @@ pub async fn execute_hook( // event network cost. Best-effort; gated by `auto-sync`. if session_start && sym.config.auto_sync { prewarm_hook_sources(sym, &deps).await; + prewarm_mcp_servers(sym, &deps).await; } // Builtin dispatch → symposium output → host agent output as Value @@ -481,6 +482,22 @@ async fn prewarm_hook_sources(sym: &Symposium, deps: &Arc) { } } +/// Acquire what MCP servers need, once per session. +/// +/// Unlike hooks, a declared `requirements` entry is acquired eagerly: that is +/// the author asking for a warm cache, and the alternative is the download +/// landing on the agent's first tool call. +async fn prewarm_mcp_servers(sym: &Symposium, deps: &Arc) { + if !sym.config.mcp.enabled { + return; + } + if deps.load().is_none() { + return; + } + let resolution = crate::mcp::resolve::resolve_with_deps(sym, deps).await; + crate::mcp::resolve::prewarm(sym, &resolution).await; +} + /// Built-in hook logic on canonical symposium types. pub async fn dispatch_builtin( sym: &Symposium, diff --git a/src/installation.rs b/src/installation.rs index afd5d381..d8cb1617 100644 --- a/src/installation.rs +++ b/src/installation.rs @@ -14,20 +14,44 @@ use symposium_install::{Runnable, acquire_source, make_executable}; /// Run a list of post-install shell commands sequentially. Stops at the first /// failure. +/// +/// Output is captured, not inherited: under `mcp-serve` stdout is the +/// JSON-RPC channel, and a warmup as ordinary as `npx -y --help` +/// prints there. pub async fn run_install_commands(commands: &[String]) -> Result<()> { for cmd in commands { - let status = tokio::process::Command::new("sh") + let output = tokio::process::Command::new("sh") .arg("-c") .arg(cmd) - .status() + .output() .await?; - if !status.success() { - bail!("install command `{cmd}` exited with {status}"); + if !output.status.success() { + let status = output.status; + match failure_tail(&output) { + Some(tail) => bail!("install command `{cmd}` exited with {status}: {tail}"), + None => bail!("install command `{cmd}` exited with {status}"), + } } } Ok(()) } +/// Trailing output from a failed install command: stderr first, stdout as +/// fallback, bounded so a verbose build does not become the error message. +fn failure_tail(output: &std::process::Output) -> Option { + const MAX: usize = 2000; + let pick = [&output.stderr, &output.stdout] + .into_iter() + .map(|stream| String::from_utf8_lossy(stream).trim().to_string()) + .find(|text| !text.is_empty())?; + + let mut start = pick.len().saturating_sub(MAX); + while start > 0 && !pick.is_char_boundary(start) { + start += 1; + } + Some(pick[start..].to_string()) +} + /// Per-installation snapshot the dispatcher builds for the command and each /// requirement. Drives env-var wiring for the spawned hook process: /// `$SYMPOSIUM_DIR_`, `$SYMPOSIUM_`, and the `$PATH` prefix. @@ -178,3 +202,43 @@ pub fn resolve_runnable(installation: AcquiredInstallation, label: &str) -> Resu }; Ok(runnable) } + +#[cfg(test)] +mod tests { + use super::*; + + /// What a failing command printed is the only account of why. + #[tokio::test] + async fn a_failing_install_command_reports_its_stderr() { + let err = run_install_commands(&["echo trouble-here >&2; exit 3".to_string()]) + .await + .expect_err("a non-zero exit must fail"); + let message = err.to_string(); + assert!(message.contains("trouble-here"), "got: {message}"); + } + + #[tokio::test] + async fn a_failing_install_command_falls_back_to_stdout() { + let err = run_install_commands(&["echo only-on-stdout; exit 1".to_string()]) + .await + .expect_err("a non-zero exit must fail"); + assert!(err.to_string().contains("only-on-stdout"), "got: {err}"); + } + + #[tokio::test] + async fn a_silent_failure_still_names_the_command() { + let err = run_install_commands(&["exit 7".to_string()]) + .await + .expect_err("a non-zero exit must fail"); + assert!(err.to_string().contains("exit 7"), "got: {err}"); + } + + #[tokio::test] + async fn commands_stop_at_the_first_failure() { + let dir = tempfile::tempdir().expect("temp dir"); + let marker = dir.path().join("second-ran"); + let commands = vec!["exit 1".to_string(), format!("touch {}", marker.display())]; + assert!(run_install_commands(&commands).await.is_err()); + assert!(!marker.exists(), "the second command should not have run"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 7ca53ed2..de4a80d9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub mod help_render; pub mod hook; pub mod hook_schema; pub(crate) mod installation; +pub mod mcp; pub mod output; pub mod plugins; pub mod pm; diff --git a/src/mcp/catalog.rs b/src/mcp/catalog.rs new file mode 100644 index 00000000..1a4781cd --- /dev/null +++ b/src/mcp/catalog.rs @@ -0,0 +1,749 @@ +//! What the workspace's tools look like to the model. +//! +//! `list_tools` answers with an index by default — one line per tool — and +//! full TypeScript declarations only when asked. Truncating a full dump would +//! be lossy and would vary with the workspace; an index is lossless and +//! strictly smaller. A single mainstream server's schemas can fill a whole +//! declaration budget on their own, so this is the common case, not a +//! precaution. +//! +//! Descriptions require a live `tools/list`, so the first call starts the +//! servers it needs. Cold start is paid at first disclosure rather than at +//! session start. + +use std::sync::Arc; +use std::time::Duration; + +use crate::config::Symposium; + +use rmcp::model::Tool; +use serde_json::Value; +use tokio::sync::Mutex; + +use super::declarations::{ + KeyMatch, ToolBinding, ToolDecl, binding_table, render_server, resolve_key, +}; +use super::dispatch::Namespace; +use super::resolve::{Rejection, Resolution, ResolvedServer, ServerCommand}; +use super::supervisor::{RestartPolicy, Supervisor}; + +/// How much to say about each tool. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Detail { + /// Name and description. Enough to choose; not enough to call. + #[default] + Names, + /// One TypeScript signature per tool. + Signatures, + /// Full declarations, including the types parameters refer to. + Full, +} + +impl Detail { + pub fn parse(value: Option<&str>) -> Self { + match value { + Some("signatures") => Self::Signatures, + Some("full") => Self::Full, + _ => Self::Names, + } + } +} + +/// Which tools to describe. +#[derive(Debug, Clone, Default)] +pub struct Query { + pub servers: Option>, + pub tools: Option>, + pub pattern: Option, + pub detail: Detail, +} + +impl Query { + /// Read a query from the tool's arguments, ignoring anything unrecognized. + pub fn from_arguments(args: &Value) -> Self { + Self { + servers: string_list(args.get("servers")), + tools: string_list(args.get("tools")), + pattern: args + .get("pattern") + .and_then(Value::as_str) + .map(str::to_string), + detail: Detail::parse(args.get("detail").and_then(Value::as_str)), + } + } + + /// Naming specific servers or tools implies wanting their details. + fn effective_detail(&self) -> Detail { + if self.detail == Detail::Names && (self.servers.is_some() || self.tools.is_some()) { + Detail::Full + } else { + self.detail + } + } + + fn wants_server(&self, name: &str) -> bool { + self.servers + .as_ref() + .is_none_or(|only| only.iter().any(|s| s == name)) + } + + fn wants_tool(&self, name: &str) -> bool { + if let Some(only) = &self.tools { + if !only.iter().any(|t| t == name) { + return false; + } + } + match &self.pattern { + Some(pattern) => glob_matches(pattern, name), + None => true, + } + } +} + +/// What the workspace resolved to, swapped as a whole so a refresh is never +/// observed half-applied. +struct CatalogState { + entries: Vec, + /// Servers that could not be used at all. Reported to the model rather + /// than only logged: a server silently missing looks like a workspace + /// that never declared it. + rejected: Vec, + /// Filter entries the caller named that match no server. + known_names: Vec, +} + +/// The workspace's backing servers, described on demand. +pub struct Catalog { + state: std::sync::RwLock>, + read_only: bool, + policy: RestartPolicy, + /// Needed to acquire an installation-backed server on first use. + sym: Arc, + /// Where the session is running, so the workspace can be resolved again. + cwd: std::path::PathBuf, + /// Modification time of `Cargo.lock` when the state was last built. + resolved_at: std::sync::Mutex>, +} + +/// What one tool call produced. +/// +/// The notice is carried beside the value rather than folded into it: wrapping +/// the result would change the shape the script sees, and the script is not who +/// the notice is for. +pub struct CallOutcome { + pub value: Value, + /// Set when the server's answer did not match the output schema it + /// declared, so the caller can tell the model its type did not hold. + pub notice: Option, +} + +struct Entry { + resolved: ResolvedServer, + /// Absent until first use. Building it may acquire an installation, which + /// must not happen at startup — a client may spawn a throwaway copy of the + /// meta-server just to probe it. + /// + /// Shared so a refresh can carry a running server into the new state. + supervisor: Arc>>, +} + +impl Catalog { + pub fn new( + sym: Arc, + resolution: Resolution, + policy: RestartPolicy, + read_only: bool, + cwd: std::path::PathBuf, + ) -> Self { + let resolved_at = cargo_lock_mtime(&cwd); + Self { + state: std::sync::RwLock::new(Arc::new(CatalogState::new(resolution, &[]))), + read_only, + policy, + sym, + cwd, + resolved_at: std::sync::Mutex::new(resolved_at), + } + } + + fn state(&self) -> Arc { + Arc::clone(&self.state.read().expect("catalog state lock")) + } + + /// Rebuild the server set when the workspace changed under us, so a + /// dependency added mid-session exposes its tools without a restart. + /// + /// Servers whose spawn is unchanged are carried across still running. + async fn refresh_if_stale(&self) { + if !self.sym.config.auto_sync { + return; + } + let Some(mtime) = cargo_lock_mtime(&self.cwd) else { + return; + }; + { + let seen = self.resolved_at.lock().expect("catalog mtime lock"); + if *seen == Some(mtime) { + return; + } + } + + let resolution = crate::mcp::resolve::resolve(&self.sym, &self.cwd).await; + let previous = self.state(); + let next = Arc::new(CatalogState::new(resolution, &previous.entries)); + + // Anything the new state did not adopt is no longer applicable. + let dropped: Vec>>> = previous + .entries + .iter() + .filter(|old| { + !next + .entries + .iter() + .any(|new| Arc::ptr_eq(&new.supervisor, &old.supervisor)) + }) + .map(|old| Arc::clone(&old.supervisor)) + .collect(); + + *self.state.write().expect("catalog state lock") = next; + *self.resolved_at.lock().expect("catalog mtime lock") = Some(mtime); + + for supervisor in dropped { + if let Some(running) = supervisor.lock().await.as_mut() { + running.shutdown().await; + } + } + } + + /// The supervisor for an entry, acquiring what it needs the first time. + async fn supervisor_for<'a>( + &self, + entry: &'a Entry, + ) -> Result>, String> { + let mut guard = entry.supervisor.lock().await; + if guard.is_none() { + let spec = entry + .resolved + .spawn_spec(&self.sym) + .await + .map_err(|e| format!("{e:#}"))?; + *guard = Some(Supervisor::new(spec, self.policy)); + } + Ok(guard) + } + + pub fn server_names(&self) -> Vec { + self.state().known_names.clone() + } + + pub fn is_empty(&self) -> bool { + self.state().entries.is_empty() + } + + /// Describe the matching tools. + pub async fn describe(&self, query: &Query) -> String { + self.refresh_if_stale().await; + let state = self.state(); + if state.entries.is_empty() && state.rejected.is_empty() { + return "No MCP servers apply to this workspace.".to_string(); + } + + let mut sections = Vec::new(); + // Refusals first: they explain an absence the model would otherwise + // have to infer. + let mut problems: Vec = state + .rejected + .iter() + .map(|r| format!("{}: {}", r.server, r.reason)) + .collect(); + + for entry in &state.entries { + if !query.wants_server(entry.resolved.name.as_str()) { + continue; + } + + let tools = match self.supervisor_for(entry).await { + Ok(mut guard) => match guard.as_mut() { + Some(supervisor) => supervisor.list_tools().await.map_err(|e| e.to_string()), + None => unreachable!("supervisor_for leaves it present"), + }, + Err(e) => Err(e), + }; + let tools = match tools { + Ok(tools) => tools, + Err(e) => { + // A server that will not start is reported in place, so + // the absence of its tools has a visible reason. + problems.push(format!("{}: {e}", entry.resolved.name.as_str())); + continue; + } + }; + + let visible: Vec<&Tool> = tools + .iter() + .filter(|t| entry.resolved.exposes(t.name.as_ref())) + .filter(|t| !self.read_only || is_read_only(t)) + .filter(|t| query.wants_tool(t.name.as_ref())) + .collect(); + + if visible.is_empty() { + // Silence here reads as a broken connection, so say why. + if !tools.is_empty() && !query_narrows(query) { + problems.push(format!( + "{}: no tools visible ({} hidden by filters)", + entry.resolved.name.as_str(), + tools.len() + )); + } + continue; + } + + sections.push(render( + entry.resolved.name.as_str(), + &visible, + query.effective_detail(), + )); + } + + // Naming a server that does not exist is a mistake worth surfacing + // rather than answering with silence. + if let Some(requested) = &query.servers { + for name in requested { + if !state.known_names.iter().any(|k| k == name) { + problems.push(format!( + "no server named `{name}`. Available: {}", + state.known_names.join(", ") + )); + } + } + } + + if sections.is_empty() && problems.is_empty() { + return "No tools matched.".to_string(); + } + + let mut out = sections.join("\n"); + if !problems.is_empty() { + if !out.is_empty() { + out.push('\n'); + } + out.push_str("Problems:\n"); + for problem in problems { + out.push_str(&format!(" {problem}\n")); + } + } + out + } + + /// The namespaces a script sees, one per server. + /// + /// Nothing is started here; a namespace resolves its tools on first call. + pub async fn namespaces(&self) -> (Vec, Vec) { + self.refresh_if_stale().await; + let state = self.state(); + let namespaces = state + .entries + .iter() + .map(|entry| Namespace { + key: namespace_key(entry.resolved.name.as_str()), + server: entry.resolved.name.as_str().to_string(), + }) + .collect(); + + // A refused server never becomes a namespace, so its absence needs a + // reason here. + let problems = state + .rejected + .iter() + .map(|r| format!("{}: {}", r.server, r.reason)) + .collect(); + + (namespaces, problems) + } + + /// Call a tool on a backing server, honoring its filters. + /// `key` is the property name the script used, which may be a sanitized + /// alias. Resolving it needs the tool list, so this is what starts the + /// server. + pub async fn call(&self, server: &str, key: &str, args: Value) -> Result { + let state = self.state(); + let Some(entry) = state.entries.iter().find(|e| e.resolved.name == server) else { + return Err(format!( + "no server named `{server}`. Available: {}", + state.known_names.join(", ") + )); + }; + + let timeout = entry.resolved.tool_call_timeout; + let mut guard = self.supervisor_for(entry).await?; + let supervisor = guard.as_mut().expect("supervisor_for leaves it present"); + + let tools = supervisor.list_tools().await.map_err(|e| e.to_string())?; + let visible: Vec<&str> = tools + .iter() + .filter(|t| entry.resolved.exposes(t.name.as_ref())) + .filter(|t| !self.read_only || is_read_only(t)) + .map(|t| t.name.as_ref()) + .collect(); + + // The same table the declarations are rendered from. + let table = binding_table(visible); + let wire_name = match resolve_key(&table, key) { + KeyMatch::One(binding) => binding.wire_name.clone(), + KeyMatch::Ambiguous(names) => { + return Err(format!( + "`{server}` has more than one tool spelled like `{key}`: {}. \ + Use one of those names exactly.", + names.join(", ") + )); + } + KeyMatch::None => return Err(unknown_tool(server, key, &table)), + }; + + // Taken before the call so the tool list, which the mutable borrow of + // the supervisor ends, is still in hand. + let declared = tools + .iter() + .find(|t| t.name.as_ref() == wire_name) + .and_then(|t| t.output_schema.clone()); + + let value = supervisor + .call(&wire_name, args, timeout) + .await + .map_err(|e| e.to_string())?; + + // Checked after unwrapping, since that is the value a script actually + // receives. A mismatch is reported, never fatal: the value may still be + // readable, and the model is the one who can decide (see + // [`crate::mcp::validate`]). + let notice = declared.and_then(|schema| { + crate::mcp::validate::check_result( + server, + &wire_name, + &Value::Object((*schema).clone()), + &value, + ) + }); + + Ok(CallOutcome { value, notice }) + } + + /// Close every running server. + pub async fn shutdown(&self) { + for entry in &self.state().entries { + if let Some(supervisor) = entry.supervisor.lock().await.as_mut() { + supervisor.shutdown().await; + } + } + } + + /// How long a script may run against this catalog's servers. + pub fn max_call_timeout(&self) -> Duration { + self.state() + .entries + .iter() + .map(|e| e.resolved.tool_call_timeout) + .max() + .unwrap_or_default() + } +} + +/// A server's name as a JavaScript global. +fn namespace_key(server: &str) -> String { + let mut out: String = server + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + if out.starts_with(|c: char| c.is_ascii_digit()) { + out.insert(0, '_'); + } + out +} + +fn query_narrows(query: &Query) -> bool { + query.tools.is_some() || query.pattern.is_some() +} + +impl CatalogState { + /// Adopts any still-matching server from `previous`, so a running child + /// survives a refresh. + fn new(resolution: Resolution, previous: &[Entry]) -> Self { + let Resolution { servers, rejected } = resolution; + let known_names = servers.iter().map(|s| s.name.clone()).collect(); + let entries = servers + .into_iter() + .map(|resolved| { + let supervisor = previous + .iter() + .find(|old| same_spawn(&old.resolved, &resolved)) + .map(|old| Arc::clone(&old.supervisor)) + .unwrap_or_default(); + Entry { + resolved, + supervisor, + } + }) + .collect(); + Self { + entries, + rejected, + known_names, + } + } +} + +/// Whether two resolutions describe the same child process. Only the spawn +/// matters; anything else that moved in the manifest does not. +fn same_spawn(a: &ResolvedServer, b: &ResolvedServer) -> bool { + a.name == b.name + && a.args == b.args + && a.env == b.env + && a.cwd == b.cwd + && match (&a.command, &b.command) { + (ServerCommand::Path(x), ServerCommand::Path(y)) => x == y, + (ServerCommand::Installation(x), ServerCommand::Installation(y)) => x.name == y.name, + _ => false, + } +} + +/// `Cargo.lock`'s modification time, searched upward from `cwd`. Walked +/// rather than asked of cargo: this runs on every describe. +fn cargo_lock_mtime(cwd: &std::path::Path) -> Option { + let mut dir = Some(cwd); + while let Some(current) = dir { + let candidate = current.join("Cargo.lock"); + if let Ok(meta) = std::fs::metadata(&candidate) { + return meta.modified().ok(); + } + dir = current.parent(); + } + None +} + +/// Report a name no tool answers to. +/// +/// The proxy answers any property, so a typo only surfaces here. Naming the +/// nearest match makes it recoverable inside the script. +fn unknown_tool(server: &str, key: &str, table: &[ToolBinding]) -> String { + let mut names: Vec<&str> = table + .iter() + .flat_map(|b| b.keys.iter().map(String::as_str)) + .collect(); + names.sort_unstable(); + + match closest(key, &names) { + Some(nearest) => format!( + "`{server}` has no tool `{key}`. Closest match: `{nearest}`. Call `{list}` for the full list.", + list = crate::mcp::server::LIST_TOOLS + ), + None => format!( + "`{server}` exposes no tools. Call `{list}` to see what is available.", + list = crate::mcp::server::LIST_TOOLS + ), + } +} + +/// The candidate sharing the longest prefix with `key`: a wrong suffix or a +/// dropped separator. +fn closest<'a>(key: &str, candidates: &[&'a str]) -> Option<&'a str> { + let normalize = |s: &str| s.to_ascii_lowercase().replace(['-', '_'], ""); + let target = normalize(key); + candidates + .iter() + .copied() + .max_by_key(|candidate| { + let other = normalize(candidate); + let shared = target + .chars() + .zip(other.chars()) + .take_while(|(a, b)| a == b) + .count(); + (shared, usize::MAX - other.len()) + }) + .filter(|candidate| { + let other = normalize(candidate); + target.chars().next() == other.chars().next() + }) +} + +fn is_read_only(tool: &Tool) -> bool { + tool.annotations + .as_ref() + .and_then(|a| a.read_only_hint) + .unwrap_or(false) +} + +fn render(server: &str, tools: &[&Tool], detail: Detail) -> String { + match detail { + Detail::Names => { + let mut out = format!("{server}:\n"); + for tool in tools { + match tool.description.as_deref().map(str::trim) { + Some(text) if !text.is_empty() => { + out.push_str(&format!(" {} - {}\n", tool.name, first_line(text))); + } + _ => out.push_str(&format!(" {}\n", tool.name)), + } + } + out + } + Detail::Signatures | Detail::Full => { + // The schemas are owned so the declaration renderer, which works + // in plain JSON, never sees a protocol type. Output schemas follow + // the input schema's gate: signatures name a tool's shape-free + // form, so spelling out the return there while hiding the + // parameter would be inconsistent. + let schemas: Vec<(Option, Option)> = tools + .iter() + .map(|tool| { + if detail != Detail::Full { + return (None, None); + } + ( + Some(Value::Object((*tool.input_schema).clone())), + tool.output_schema + .as_ref() + .map(|schema| Value::Object((**schema).clone())), + ) + }) + .collect(); + let decls: Vec = tools + .iter() + .zip(&schemas) + .map(|(tool, (input, output))| ToolDecl { + name: tool.name.as_ref(), + description: tool.description.as_deref(), + // Signatures name the parameter; full spells out its shape. + input_schema: input.as_ref(), + output_schema: output.as_ref(), + }) + .collect(); + render_server(server, &decls) + } + } +} + +fn first_line(text: &str) -> String { + text.lines().next().unwrap_or_default().trim().to_string() +} + +fn string_list(value: Option<&Value>) -> Option> { + let array = value?.as_array()?; + Some( + array + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect(), + ) +} + +/// Match a name against a pattern where `*` stands for any run of characters. +/// +/// A dependency for this would be more machinery than the feature is worth. +fn glob_matches(pattern: &str, name: &str) -> bool { + let parts: Vec<&str> = pattern.split('*').collect(); + if parts.len() == 1 { + return pattern == name; + } + let mut rest = name; + for (index, part) in parts.iter().enumerate() { + if part.is_empty() { + continue; + } + match index { + // A pattern not starting with `*` must match from the front. + 0 => match rest.strip_prefix(part) { + Some(tail) => rest = tail, + None => return false, + }, + _ if index == parts.len() - 1 => { + // The final piece must land at the end. + return rest.ends_with(part) && rest.len() >= part.len(); + } + _ => match rest.find(part) { + Some(at) => rest = &rest[at + part.len()..], + None => return false, + }, + } + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The global a script uses must be a legal identifier even when the + /// server's declared name is not. + #[test] + fn namespace_keys_are_identifiers() { + assert_eq!(namespace_key("sqlx"), "sqlx"); + assert_eq!(namespace_key("sea-orm"), "sea_orm"); + assert_eq!(namespace_key("2fa"), "_2fa"); + } + + #[test] + fn detail_defaults_to_names() { + assert_eq!(Detail::parse(None), Detail::Names); + assert_eq!(Detail::parse(Some("nonsense")), Detail::Names); + assert_eq!(Detail::parse(Some("full")), Detail::Full); + assert_eq!(Detail::parse(Some("signatures")), Detail::Signatures); + } + + /// Asking about specific servers or tools is a request for detail; making + /// the caller also pass `detail` would be a needless second round trip. + #[test] + fn naming_a_target_implies_wanting_detail() { + let query = Query { + servers: Some(vec!["sqlx".into()]), + ..Query::default() + }; + assert_eq!(query.effective_detail(), Detail::Full); + + let unfiltered = Query::default(); + assert_eq!(unfiltered.effective_detail(), Detail::Names); + } + + #[test] + fn explicit_detail_is_respected_even_when_filtering() { + let query = Query { + servers: Some(vec!["sqlx".into()]), + detail: Detail::Signatures, + ..Query::default() + }; + assert_eq!(query.effective_detail(), Detail::Signatures); + } + + #[test] + fn arguments_are_read_leniently() { + let query = Query::from_arguments(&serde_json::json!({ + "servers": ["a", "b"], + "pattern": "get_*", + "detail": "full", + "unrecognized": 1 + })); + assert_eq!(query.servers, Some(vec!["a".into(), "b".into()])); + assert_eq!(query.pattern.as_deref(), Some("get_*")); + assert_eq!(query.detail, Detail::Full); + assert_eq!(query.tools, None); + } + + // -- glob -- + + #[test] + fn glob_matches_prefixes_suffixes_and_middles() { + assert!(glob_matches("get_*", "get_user")); + assert!(!glob_matches("get_*", "set_user")); + assert!(glob_matches("*_user", "get_user")); + assert!(!glob_matches("*_user", "get_team")); + assert!(glob_matches("get_*_by_*", "get_user_by_id")); + assert!(glob_matches("*", "anything")); + } + + #[test] + fn glob_without_a_wildcard_is_an_exact_match() { + assert!(glob_matches("query", "query")); + assert!(!glob_matches("query", "query2")); + } +} diff --git a/src/mcp/client.rs b/src/mcp/client.rs new file mode 100644 index 00000000..b7227223 --- /dev/null +++ b/src/mcp/client.rs @@ -0,0 +1,370 @@ +//! Talking to one backing MCP server. +//! +//! Everything protocol-shaped is confined here. The rest of `mcp` works in +//! `serde_json::Value`, so a future SDK change touches this file and nothing +//! else — worth insisting on, given the SDK moved a major version during this +//! work. +//! +//! Two behaviors are less obvious than they look: +//! +//! * **A tool failure is not a protocol failure.** A server reports "table +//! not found" as a successful response carrying an error flag. Conflating +//! the two loses the message the model needs. +//! * **A result has to be unwrapped.** The wire form is a content envelope, +//! but a script wants the value. See [`unwrap_result`]. + +use std::path::PathBuf; +use std::time::Duration; + +use rmcp::ServiceExt; +use rmcp::model::{CallToolRequestParams, CallToolResult, ContentBlock, ProtocolVersion, Tool}; +use rmcp::service::{RoleClient, RunningService}; +use rmcp::transport::TokioChildProcess; +use serde_json::{Map, Value}; + +/// Everything needed to start a backing server. +#[derive(Debug, Clone)] +pub struct SpawnSpec { + pub name: String, + pub command: PathBuf, + pub args: Vec, + pub env: Vec<(String, String)>, + /// Directory to run in. A server that reads the project it serves needs + /// this; four of the seven client config formats have it. + pub cwd: Option, + pub startup_timeout: Duration, +} + +/// Why talking to a backing server failed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClientError { + /// Spawn or handshake did not finish in time; `detail` is the stderr tail. + StartupTimeout { + server: String, + limit_secs: u64, + detail: Option, + }, + /// The process could not be started, or died during the handshake. + StartupFailed { server: String, detail: String }, + /// A single call did not finish in time. + CallTimeout { + server: String, + tool: String, + limit_secs: u64, + }, + /// The connection broke, or the server rejected the request. + Protocol { server: String, detail: String }, + /// The tool ran and reported failure. Distinct from the above: the + /// message is the server's own, and belongs in front of the model. + Tool { message: String }, +} + +impl std::fmt::Display for ClientError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::StartupTimeout { + server, + limit_secs, + detail, + } => match detail { + Some(tail) => write!(f, "{server} did not start within {limit_secs}s: {tail}"), + None => write!(f, "{server} did not start within {limit_secs}s"), + }, + Self::StartupFailed { server, detail } => { + write!(f, "{server} failed to start: {detail}") + } + Self::CallTimeout { + server, + tool, + limit_secs, + } => write!(f, "{server}.{tool} did not answer within {limit_secs}s"), + Self::Protocol { server, detail } => write!(f, "{server}: {detail}"), + Self::Tool { message } => write!(f, "{message}"), + } + } +} + +impl std::error::Error for ClientError {} + +/// A connected backing server. +pub struct BackingServer { + name: String, + service: RunningService, + protocol_version: ProtocolVersion, +} + +impl BackingServer { + /// Spawn the server and complete the handshake. + /// + /// The SDK waits on the handshake indefinitely, so the deadline is + /// imposed here. A server fetched on first use can spend most of its + /// budget just downloading. + pub async fn spawn(spec: &SpawnSpec) -> Result { + let mut command = tokio::process::Command::new(&spec.command); + command.args(&spec.args); + for (key, value) in &spec.env { + command.env(key, value); + } + if let Some(cwd) = &spec.cwd { + command.current_dir(cwd); + } + + // Stderr is captured from spawn rather than after the handshake: + // when startup fails, its tail is the only account of why. + let (transport, stderr) = TokioChildProcess::builder(command) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| ClientError::StartupFailed { + server: spec.name.clone(), + detail: e.to_string(), + })?; + + let started = tokio::time::timeout(spec.startup_timeout, ().serve(transport)).await; + + let service = match started { + Ok(Ok(service)) => service, + Ok(Err(e)) => { + let detail = match drain(stderr).await { + Some(tail) if !tail.is_empty() => format!("{e}: {tail}"), + _ => e.to_string(), + }; + return Err(ClientError::StartupFailed { + server: spec.name.clone(), + detail, + }); + } + Err(_) => { + // A server that hung mid-handshake usually said why on stderr. + let detail = drain(stderr).await.filter(|tail| !tail.is_empty()); + return Err(ClientError::StartupTimeout { + server: spec.name.clone(), + limit_secs: spec.startup_timeout.as_secs(), + detail, + }); + } + }; + + let protocol_version = service + .peer_info() + .map(|info| info.protocol_version.clone()) + .unwrap_or_default(); + + Ok(Self { + name: spec.name.clone(), + service, + protocol_version, + }) + } + + pub fn name(&self) -> &str { + &self.name + } + + /// The version actually negotiated. + /// + /// Worth recording rather than assuming: structured output arrived in + /// 2025-06-18, and servers pinning an earlier version are deployed today. + pub fn protocol_version(&self) -> &ProtocolVersion { + &self.protocol_version + } + + pub async fn list_tools(&self) -> Result, ClientError> { + self.service + .list_all_tools() + .await + .map_err(|e| ClientError::Protocol { + server: self.name.clone(), + detail: e.to_string(), + }) + } + + pub async fn call( + &self, + tool: &str, + args: Value, + timeout: Duration, + ) -> Result { + let params = CallToolRequestParams::new(tool.to_string()); + let params = match args { + Value::Object(map) => params.with_arguments(map), + Value::Null => params.with_arguments(Map::new()), + // A non-object argument has no place in the wire form. + other => params.with_arguments(Map::from_iter([("value".to_string(), other)])), + }; + + let response = tokio::time::timeout(timeout, self.service.call_tool(params)) + .await + .map_err(|_| ClientError::CallTimeout { + server: self.name.clone(), + tool: tool.to_string(), + limit_secs: timeout.as_secs(), + })? + .map_err(|e| ClientError::Protocol { + server: self.name.clone(), + detail: e.to_string(), + })?; + + unwrap_result(&response) + } + + /// Close the connection, giving the server a chance to exit cleanly. + pub async fn shutdown(self) { + let _ = self.service.cancel().await; + } +} + +/// Reduce a tool response to the value a script should see. +/// +/// The order matters. A server may set the error flag *and* return structured +/// content; checking content first would return the payload and silently drop +/// the failure. +pub fn unwrap_result(result: &CallToolResult) -> Result { + if result.is_error.unwrap_or(false) { + return Err(ClientError::Tool { + message: joined_text(&result.content).unwrap_or_else(|| "tool failed".to_string()), + }); + } + + if let Some(structured) = &result.structured_content { + return Ok(unwrap_framework_envelope(structured.clone())); + } + + // All-text is the common case, and multi-block text results are ordinary. + if let Some(text) = joined_text(&result.content) { + return Ok(serde_json::from_str(&text).unwrap_or(Value::String(text))); + } + + // Mixed or binary content has no scalar form; hand back the envelope + // rather than inventing one. + Ok(serde_json::to_value(&result.content).unwrap_or(Value::Null)) +} + +/// Unwrap the single-key envelope one major server framework adds around +/// non-object return values. +/// +/// It wraps any scalar or list in `{"result": ...}` and flags it on the +/// output schema. Passing that through would hand the model a wrapper it +/// never asked for, and the framework is common enough that this is not an +/// edge case. +fn unwrap_framework_envelope(value: Value) -> Value { + let Value::Object(map) = &value else { + return value; + }; + if map.len() == 1 { + if let Some(inner) = map.get("result") { + return inner.clone(); + } + } + value +} + +fn joined_text(content: &[ContentBlock]) -> Option { + let mut parts = Vec::new(); + for block in content { + match block { + ContentBlock::Text(text) => parts.push(text.text.clone()), + // A single non-text block means this is not a text result. + _ => return None, + } + } + (!parts.is_empty()).then(|| parts.join("\n")) +} + +async fn drain(stderr: Option) -> Option { + use tokio::io::AsyncReadExt; + let mut stderr = stderr?; + let mut buffer = Vec::new(); + let _ = tokio::time::timeout(Duration::from_millis(200), stderr.read_to_end(&mut buffer)).await; + let text = String::from_utf8_lossy(&buffer).trim().to_string(); + // Only the tail is useful, and an unbounded one could carry secrets far. + Some( + text.chars() + .rev() + .take(400) + .collect::>() + .into_iter() + .rev() + .collect(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use rmcp::model::{CallToolResult, ContentBlock}; + use serde_json::json; + + fn text_result(parts: &[&str]) -> CallToolResult { + CallToolResult::success(parts.iter().map(|t| ContentBlock::text(*t)).collect()) + } + + // -- unwrapping -- + + #[test] + fn structured_content_is_returned_directly() { + let result = CallToolResult::structured(json!({"rows": [1, 2]})); + assert_eq!(unwrap_result(&result).unwrap(), json!({"rows": [1, 2]})); + } + + /// A server can report failure *and* attach structured content. Reading + /// the content first would return a payload and lose the error. + #[test] + fn error_flag_wins_over_structured_content() { + let mut result = CallToolResult::structured(json!({"rows": []})); + result.is_error = Some(true); + result.content = vec![ContentBlock::text("table not found")]; + + let err = unwrap_result(&result).unwrap_err(); + assert_eq!( + err, + ClientError::Tool { + message: "table not found".to_string() + } + ); + } + + #[test] + fn text_that_parses_as_json_is_parsed() { + let result = text_result(&[r#"{"ok": true}"#]); + assert_eq!(unwrap_result(&result).unwrap(), json!({"ok": true})); + } + + #[test] + fn plain_text_is_returned_as_a_string() { + let result = text_result(&["up to date"]); + assert_eq!(unwrap_result(&result).unwrap(), json!("up to date")); + } + + /// Multi-block text results are ordinary, not an edge case. + #[test] + fn several_text_blocks_are_joined() { + let result = text_result(&["line one", "line two"]); + assert_eq!(unwrap_result(&result).unwrap(), json!("line one\nline two")); + } + + /// One widely used server framework wraps every non-object return value + /// in a single-key envelope. + #[test] + fn framework_result_envelope_is_unwrapped() { + let result = CallToolResult::structured(json!({"result": [1, 2, 3]})); + assert_eq!(unwrap_result(&result).unwrap(), json!([1, 2, 3])); + } + + /// An ordinary object that happens to have one key is not an envelope + /// unless that key is the envelope's. + #[test] + fn single_key_objects_are_not_mistaken_for_envelopes() { + let result = CallToolResult::structured(json!({"rows": [1]})); + assert_eq!(unwrap_result(&result).unwrap(), json!({"rows": [1]})); + } + + #[test] + fn error_without_text_still_reports_failure() { + let mut result = CallToolResult::success(vec![]); + result.is_error = Some(true); + assert!(matches!( + unwrap_result(&result).unwrap_err(), + ClientError::Tool { .. } + )); + } +} diff --git a/src/mcp/console.rs b/src/mcp/console.rs new file mode 100644 index 00000000..55db598b --- /dev/null +++ b/src/mcp/console.rs @@ -0,0 +1,200 @@ +//! Capturing what a script logs. +//! +//! Scripts are written blind — the model cannot step through one — so +//! `console.log` is the only way it can see what happened on the way to a +//! result. The output is captured rather than printed: the process speaks +//! JSON-RPC on stdout, and anything written there would corrupt the stream. +//! +//! The buffer is bounded. A loop that logs on every iteration would otherwise +//! fill the agent's context with noise, which is the cost this whole design +//! exists to avoid. + +use std::cell::RefCell; +use std::rc::Rc; + +use rquickjs::function::Rest; +use rquickjs::{CatchResultExt, Ctx, Function, Object}; + +/// Console output from one script. +#[derive(Debug, Default)] +pub struct Log { + entries: Vec, + bytes: usize, + limit: usize, + dropped: bool, +} + +/// Shared with the host functions installed in the context. +/// +/// `Rc` over `Arc` since the engine and its closures live on one thread. +pub type Shared = Rc>; + +impl Log { + pub fn shared(limit: usize) -> Shared { + Rc::new(RefCell::new(Self { + limit, + ..Self::default() + })) + } + + fn push(&mut self, line: String) { + if self.bytes + line.len() > self.limit { + self.dropped = true; + return; + } + self.bytes += line.len(); + self.entries.push(line); + } + + pub fn entries(&self) -> &[String] { + &self.entries + } + + /// Whether anything was discarded for want of budget. + pub fn dropped(&self) -> bool { + self.dropped + } +} + +/// Install a `console` object backed by `log`. +pub fn install<'js>(ctx: &Ctx<'js>, log: &Shared) -> Result<(), String> { + let console = Object::new(ctx.clone()) + .catch(ctx) + .map_err(|e| e.to_string())?; + + // `debug` and `trace` are deliberately included: a model reaching for + // them should not hit an undefined method mid-script. + for (method, prefix) in [ + ("log", ""), + ("info", ""), + ("debug", ""), + ("trace", ""), + ("warn", "[warn] "), + ("error", "[error] "), + ] { + let log = Rc::clone(log); + let prefix = prefix.to_string(); + let function = Function::new( + ctx.clone(), + move |ctx: Ctx<'js>, args: Rest>| { + let line = format!("{prefix}{}", format_args_for_log(&ctx, &args.0)); + log.borrow_mut().push(line); + }, + ) + .catch(ctx) + .map_err(|e| e.to_string())?; + + console + .set(method, function) + .catch(ctx) + .map_err(|e| e.to_string())?; + } + + ctx.globals() + .set("console", console) + .catch(ctx) + .map_err(|e| e.to_string()) +} + +/// Render call arguments the way a developer console would: strings bare, +/// everything else as JSON. +fn format_args_for_log<'js>(ctx: &Ctx<'js>, args: &[rquickjs::Value<'js>]) -> String { + args.iter() + .map(|value| { + if let Some(text) = value.as_string() { + return text.to_string().unwrap_or_default(); + } + if value.is_undefined() { + return "undefined".to_string(); + } + match ctx.json_stringify(value.clone()) { + Ok(Some(encoded)) => encoded.to_string().unwrap_or_default(), + // A value with no JSON form, such as a function. + _ => "undefined".to_string(), + } + }) + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use crate::mcp::sandbox::{Limits, Sandbox}; + use std::time::Duration; + + async fn logs_of(source: &str, limit: usize) -> (Vec, bool) { + let sandbox = Sandbox::new(Limits { + timeout: Duration::from_secs(5), + max_console_bytes: limit, + ..Limits::default() + }); + let outcome = sandbox.run_script(source).await.unwrap(); + (outcome.logs, outcome.logs_dropped) + } + + #[tokio::test] + async fn captures_log_output_in_order() { + let (logs, dropped) = + logs_of(r#"console.log("a"); console.log("b"); return 1;"#, 1024).await; + assert_eq!(logs, vec!["a".to_string(), "b".to_string()]); + assert!(!dropped); + } + + #[tokio::test] + async fn renders_values_as_json_and_strings_bare() { + let (logs, _) = logs_of( + r#"console.log("n =", 1, { a: [true, null] }); return 0;"#, + 1024, + ) + .await; + assert_eq!(logs, vec![r#"n = 1 {"a":[true,null]}"#.to_string()]); + } + + #[tokio::test] + async fn marks_severity_for_warn_and_error() { + let (logs, _) = logs_of(r#"console.warn("w"); console.error("e"); return 0;"#, 1024).await; + assert_eq!(logs, vec!["[warn] w".to_string(), "[error] e".to_string()]); + } + + /// A logging loop must not be able to fill the agent's context. + #[tokio::test] + async fn output_is_bounded() { + let (logs, dropped) = logs_of( + r#"for (let i = 0; i < 10000; i++) console.log("noise"); return "done";"#, + 64, + ) + .await; + assert!(dropped, "the budget should have been exhausted"); + assert!( + logs.join("").len() <= 64, + "kept {} bytes, over budget", + logs.join("").len() + ); + } + + /// Exhausting the budget bounds the output, not the script. + #[tokio::test] + async fn exhausted_budget_does_not_fail_the_script() { + let sandbox = Sandbox::new(Limits { + timeout: Duration::from_secs(5), + max_console_bytes: 8, + ..Limits::default() + }); + let outcome = sandbox + .run_script(r#"console.log("a much longer line than the budget"); return 42;"#) + .await + .unwrap(); + assert_eq!(outcome.json, "42"); + assert!(outcome.logs_dropped); + } + + #[tokio::test] + async fn console_methods_a_model_may_reach_for_all_exist() { + let (logs, _) = logs_of( + r#"["log","info","debug","trace","warn","error"].forEach(m => console[m]("x")); return 0;"#, + 1024, + ) + .await; + assert_eq!(logs.len(), 6, "got: {logs:?}"); + } +} diff --git a/src/mcp/corpus_tests.rs b/src/mcp/corpus_tests.rs new file mode 100644 index 00000000..af299095 --- /dev/null +++ b/src/mcp/corpus_tests.rs @@ -0,0 +1,188 @@ +//! Snapshots of generated declarations for real MCP servers. +//! +//! The payloads in `testdata/` are verbatim `tools/list` responses captured +//! from published servers, chosen to span four independent schema generators: +//! two flavours of the TypeScript `zod` toolchain (draft-07 and 2020-12), +//! plus the reference servers' own hand-written schemas. +//! +//! These are the tests that catch a dialect we handle wrongly. A construct +//! that starts rendering as `unknown` shows up as a diff in the checked-in +//! `.d.ts`, which the synthetic unit tests cannot detect because they only +//! cover shapes we already thought of. +//! +//! Regenerate with `UPDATE_EXPECT=1 cargo test`. + +use expect_test::expect_file; +use serde_json::Value; + +use super::declarations::{ToolDecl, render_server}; + +/// Every captured payload, with the server name its declarations use. +const CORPUS: &[(&str, &str)] = &[ + (include_str!("testdata/constructs.tools.json"), "constructs"), + (include_str!("testdata/everything.tools.json"), "everything"), + (include_str!("testdata/filesystem.tools.json"), "filesystem"), + (include_str!("testdata/memory.tools.json"), "memory"), + (include_str!("testdata/playwright.tools.json"), "playwright"), + ( + include_str!("testdata/sequentialthinking.tools.json"), + "sequentialthinking", + ), +]; + +/// Render a captured `tools/list` payload as one server's declarations. +fn render_corpus(payload: &str, server: &str) -> String { + let parsed: Value = serde_json::from_str(payload).expect("payload should be valid JSON"); + let tools = parsed["tools"] + .as_array() + .expect("payload should carry a `tools` array"); + + let decls: Vec = tools + .iter() + .map(|tool| ToolDecl { + name: tool["name"].as_str().unwrap_or_default(), + description: tool["description"].as_str(), + input_schema: tool.get("inputSchema"), + output_schema: tool.get("outputSchema"), + }) + .collect(); + + render_server(server, &decls) +} + +/// Every corpus entry renders something for every tool, and nothing panics. +/// +/// The per-server snapshots below cover the content; this covers the +/// invariant that generation never fails, whatever a server sends. +#[test] +fn every_tool_in_the_corpus_produces_a_declaration() { + for (payload, server) in CORPUS { + let parsed: Value = serde_json::from_str(payload).unwrap(); + let expected = parsed["tools"].as_array().unwrap().len(); + let rendered = render_corpus(payload, server); + // Return-type agnostic: a tool that declares an output schema renders + // `Promise<{ ... }>` rather than `Promise`. + let found = rendered.matches("): Promise<").count(); + assert!( + found >= expected, + "{server}: expected at least {expected} declarations, found {found}" + ); + } +} + +/// The protocol's own test server. Names 12 of its 13 tools with hyphens, so +/// this is also the identifier-handling snapshot. +#[test] +fn everything_server() { + expect_file!["testdata/everything.d.ts"].assert_eq(&render_corpus( + include_str!("testdata/everything.tools.json"), + "everything", + )); +} + +/// Carries the corpus's only union-with-literal and declares output schemas +/// on every tool. +#[test] +fn filesystem_server() { + expect_file!["testdata/filesystem.d.ts"].assert_eq(&render_corpus( + include_str!("testdata/filesystem.tools.json"), + "filesystem", + )); +} + +/// Deeply nested schemas: the most schema nodes per byte in the corpus. +#[test] +fn memory_server() { + expect_file!["testdata/memory.d.ts"].assert_eq(&render_corpus( + include_str!("testdata/memory.tools.json"), + "memory", + )); +} + +/// The 2020-12 dialect, and the corpus's only typed `additionalProperties` +/// and `propertyNames`. +#[test] +fn playwright_server() { + expect_file!["testdata/playwright.d.ts"].assert_eq(&render_corpus( + include_str!("testdata/playwright.tools.json"), + "playwright", + )); +} + +/// One tool with a very large description — the size-control case, where the +/// payload is dominated by prose rather than by schema. +#[test] +fn sequentialthinking_server() { + expect_file!["testdata/sequentialthinking.d.ts"].assert_eq(&render_corpus( + include_str!("testdata/sequentialthinking.tools.json"), + "sequentialthinking", + )); +} + +/// `$ref`/`$defs`, the composition keywords, tuples and type-less schemas — +/// what pydantic, zod and schemars emit for an ordinary model, and the one +/// payload here that is hand-built rather than captured. +#[test] +fn construct_coverage() { + expect_file!["testdata/constructs.d.ts"].assert_eq(&render_corpus( + include_str!("testdata/constructs.tools.json"), + "constructs", + )); +} + +/// The generated declarations have to parse: `expect_file!` locks the bytes, +/// not whether a compiler accepts them. +/// +/// `tsc` stops at syntax errors and never reaches type checking, so a +/// declaration that parses with the wrong shape passes here — the snapshots +/// above are what cover that. +/// +/// Skips when no TypeScript is reachable; CI installs Node so it does not +/// skip there. +#[test] +fn declarations_type_check() { + let dir = tempfile::tempdir().expect("temp dir"); + let mut files = Vec::new(); + for (payload, server) in CORPUS { + // One file per server: two servers may legitimately name a type alike. + let name = format!("{server}.d.ts"); + std::fs::write(dir.path().join(&name), render_corpus(payload, server)) + .expect("write declarations"); + files.push(name); + } + + let run = std::process::Command::new("npx") + .args([ + "-y", + "-p", + "typescript@5", + "tsc", + "--noEmit", + "--strict", + "--skipLibCheck", + ]) + .args(&files) + .current_dir(dir.path()) + .output(); + + let Ok(output) = run else { + eprintln!("skipping declarations_type_check: no npx on PATH"); + return; + }; + let report = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + + // A failure carrying no `error TS` is the toolchain not running + // (no network for the download, say), not a type error. + if !output.status.success() && !report.contains("error TS") { + eprintln!("skipping declarations_type_check: tsc did not run:\n{report}"); + return; + } + assert!( + output.status.success(), + "generated declarations do not type-check:\n{report}" + ); +} diff --git a/src/mcp/declarations.rs b/src/mcp/declarations.rs new file mode 100644 index 00000000..291112db --- /dev/null +++ b/src/mcp/declarations.rs @@ -0,0 +1,697 @@ +//! Rendering a backing server's tools as TypeScript declarations. +//! +//! Each server becomes one object of methods, so a script reads as +//! `await sqlx.query({ sql: "..." })`. The object form — rather than a +//! `declare namespace` — is what lets a tool whose wire name is not a +//! JavaScript identifier still be declared, since an object type accepts +//! quoted method names. + +use serde_json::Value; + +use super::schema_to_ts::{TypeRenderer, jsdoc_text}; + +/// One tool as advertised by a backing server. +#[derive(Debug, Clone, Copy)] +pub struct ToolDecl<'a> { + /// The name used on the wire, which need not be a JavaScript identifier. + pub name: &'a str, + pub description: Option<&'a str>, + pub input_schema: Option<&'a Value>, + /// The tool's declared output schema, when it has one. Rendered as the + /// return type, and checked against the returned value at call time. + pub output_schema: Option<&'a Value>, +} + +/// The JavaScript keys one tool answers to, primary first. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToolBinding { + /// Name to send on the wire. + pub wire_name: String, + /// Property keys, as they appear on the namespace object. A wire name + /// that is already an identifier has one; anything else also gets a + /// sanitized alias, so `sqlx["migrate-status"]` and + /// `sqlx.migrate_status` both dispatch. + pub keys: Vec, +} + +/// Assign JavaScript keys to a server's tools. +/// +/// The single source of both the declarations and the runtime bindings, so a +/// name the model is shown is one it can call. +/// +/// Two passes: primaries before aliases, so a tool keeps its own spelling +/// rather than losing it to another tool's sanitized form. +pub fn binding_table<'a>(names: impl IntoIterator) -> Vec { + let mut used: Vec = Vec::new(); + let mut table: Vec = Vec::new(); + + for name in names { + // Tool names are unique per server by the protocol; a repeat is + // unaddressable on the wire either way. + if table.iter().any(|b| b.wire_name == name) { + continue; + } + table.push(ToolBinding { + wire_name: name.to_string(), + keys: vec![unique(name.to_string(), &mut used)], + }); + } + + for binding in &mut table { + if !is_js_identifier(&binding.wire_name) { + binding + .keys + .push(unique(sanitize(&binding.wire_name), &mut used)); + } + } + + table +} + +/// A spelling collapsed for tolerant matching: case and punctuation dropped, so +/// `create_entities`, `createEntities` and `create-entities` agree. +fn normalized_key(name: &str) -> String { + name.chars() + .filter(char::is_ascii_alphanumeric) + .map(|c| c.to_ascii_lowercase()) + .collect() +} + +/// What the property name a script used resolved to. +pub enum KeyMatch<'a> { + One(&'a ToolBinding), + /// Wire names of tools whose spellings collapse together. + Ambiguous(Vec<&'a str>), + None, +} + +/// Resolve a script's property name to a tool. +/// +/// Declared spellings match exactly first, so two tools differing only in +/// punctuation stay distinct. Everything else falls back to normalized +/// matching, which is what lets a model reach a snake_case tool by the +/// camelCase name TypeScript habit suggests. An ambiguous fallback is refused +/// rather than guessed. +pub fn resolve_key<'a>(table: &'a [ToolBinding], key: &str) -> KeyMatch<'a> { + if let Some(binding) = table.iter().find(|b| b.keys.iter().any(|k| k == key)) { + return KeyMatch::One(binding); + } + + let wanted = normalized_key(key); + if wanted.is_empty() { + return KeyMatch::None; + } + + let mut hits = table + .iter() + .filter(|b| b.keys.iter().any(|k| normalized_key(k) == wanted)); + + match (hits.next(), hits.next()) { + (Some(binding), None) => KeyMatch::One(binding), + (Some(first), Some(second)) => { + let mut names = vec![first.wire_name.as_str(), second.wire_name.as_str()]; + names.extend(hits.map(|b| b.wire_name.as_str())); + KeyMatch::Ambiguous(names) + } + _ => KeyMatch::None, + } +} + +/// Render one server's tools as a declaration block. +pub fn render_server(server: &str, tools: &[ToolDecl]) -> String { + let mut types = TypeRenderer::new(); + let mut methods = String::new(); + + for binding in binding_table(tools.iter().map(|t| t.name)) { + let Some(tool) = tools.iter().find(|t| t.name == binding.wire_name) else { + continue; + }; + let params = render_params(&mut types, tool.input_schema); + let result = render_result(&mut types, tool.output_schema); + + for (index, key) in binding.keys.iter().enumerate() { + if index == 0 { + if let Some(doc) = tool.description.and_then(jsdoc_text) { + methods.push_str(&format!(" /** {doc} */\n")); + } + } else { + // The alias is the same tool under a different spelling. + // Repeating the description would double it in the output. + let name = jsdoc_text(tool.name).unwrap_or_else(|| "the same tool".to_string()); + methods.push_str(&format!(" /** Alias for {name}. */\n")); + } + methods.push_str(&format!(" {}({params}): {result};\n", render_key(key))); + } + } + + let mut out = types.declarations(); + out.push_str(&format!( + "declare const {}: {{\n{methods}}};\n", + sanitize(server) + )); + out +} + +/// A property key as it must be written in a type literal. +fn render_key(key: &str) -> String { + if is_js_identifier(key) { + key.to_string() + } else { + format!("{key:?}") + } +} + +/// Render a tool's parameter list. +/// +/// A tool with no properties takes no argument at all, and one whose +/// properties are all optional takes an optional argument — both save the +/// model from passing an empty object. +fn render_params(types: &mut TypeRenderer, schema: Option<&Value>) -> String { + let Some(schema) = schema else { + return String::new(); + }; + if !has_properties(schema) { + return String::new(); + } + let optional = if has_required(schema) { "" } else { "?" }; + // Indent one level: the type sits inside the server object's braces. + format!("params{optional}: {}", types.render_indented(schema, 1)) +} + +/// Render a tool's return type. +/// +/// A declared output schema becomes the return type; a tool without one stays +/// `unknown`, which forces the model to narrow rather than assume a shape. +/// +/// Nothing obliges a server to send what it declared, so this is intent rather +/// than guarantee. The same schema is checked against the value at call time +/// and a mismatch is tagged, not refused — see [`crate::mcp::validate`]. +fn render_result(types: &mut TypeRenderer, schema: Option<&Value>) -> String { + match schema { + // Same indent as the parameter type: both sit inside the server + // object's braces. + Some(schema) => format!("Promise<{}>", types.render_indented(schema, 1)), + None => "Promise".to_string(), + } +} + +fn has_properties(schema: &Value) -> bool { + schema + .get("properties") + .and_then(Value::as_object) + .is_some_and(|p| !p.is_empty()) +} + +fn has_required(schema: &Value) -> bool { + schema + .get("required") + .and_then(Value::as_array) + .is_some_and(|r| !r.is_empty()) +} + +/// Coerce a wire name into a JavaScript identifier. +fn sanitize(name: &str) -> String { + let mut out: String = name + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + if out.is_empty() { + return "_".to_string(); + } + if out.starts_with(|c: char| c.is_ascii_digit()) { + out.insert(0, '_'); + } + out +} + +/// Keep names distinct. Two wire names differing only in punctuation sanitize +/// to the same identifier; this is rare but must not silently drop a tool. +fn unique(name: String, used: &mut Vec) -> String { + let mut candidate = name.clone(); + let mut n = 2; + while used.contains(&candidate) { + candidate = format!("{name}_{n}"); + n += 1; + } + used.push(candidate.clone()); + candidate +} + +fn is_js_identifier(name: &str) -> bool { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !(first.is_ascii_alphabetic() || first == '_' || first == '$') { + return false; + } + chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn tool<'a>(name: &'a str, schema: &'a Value) -> ToolDecl<'a> { + ToolDecl { + name, + description: None, + input_schema: Some(schema), + output_schema: None, + } + } + + #[test] + fn renders_a_tool_with_parameters() { + let schema = json!({ + "type": "object", + "properties": {"sql": {"type": "string"}}, + "required": ["sql"], + }); + let out = render_server("sqlx", &[tool("query", &schema)]); + assert_eq!( + out, + "declare const sqlx: {\n query(params: {\n sql: string;\n }): Promise;\n};\n" + ); + } + + /// A tool that declares no output schema stays untyped. `unknown` forces + /// the model to narrow rather than assume a shape nobody promised. + #[test] + fn a_tool_without_an_output_schema_returns_unknown() { + let schema = json!({"type": "object", "properties": {"a": {"type": "string"}}}); + let out = render_server("s", &[tool("t", &schema)]); + assert!(out.contains("): Promise;"), "got:\n{out}"); + } + + fn typed_tool<'a>(name: &'a str, input: &'a Value, output: &'a Value) -> ToolDecl<'a> { + ToolDecl { + name, + description: None, + input_schema: Some(input), + output_schema: Some(output), + } + } + + #[test] + fn a_declared_output_schema_becomes_the_return_type() { + let input = json!({"type": "object", "properties": {"q": {"type": "string"}}}); + let output = json!({ + "type": "object", + "properties": {"count": {"type": "integer"}}, + "required": ["count"], + }); + let out = render_server("s", &[typed_tool("search", &input, &output)]); + assert!( + out.contains("): Promise<{\n count: number;\n }>;"), + "got:\n{out}" + ); + assert!( + !out.contains("Promise"), + "a declared shape should replace `unknown`:\n{out}" + ); + } + + /// A named type in an output schema goes through the same hoisting as one + /// in a parameter schema, so it is declared once above the server object. + #[test] + fn a_named_type_in_an_output_schema_is_hoisted() { + let input = json!({"type": "object"}); + let output = json!({ + "type": "object", + "properties": {"hit": {"$ref": "#/definitions/Hit"}}, + "definitions": { + "Hit": { + "title": "Hit", + "type": "object", + "properties": {"score": {"type": "number"}}, + }, + }, + }); + let out = render_server("s", &[typed_tool("find", &input, &output)]); + let server_at = out.find("declare const s:").expect("server declared"); + let hit_at = out.find("Hit").expect("named type rendered"); + assert!( + hit_at < server_at, + "the named type should precede the server object:\n{out}" + ); + } + + /// Both spellings of a hyphenated tool are the same tool, so both carry the + /// same return type. + #[test] + fn an_aliased_tool_carries_its_return_type_on_both_spellings() { + let input = json!({"type": "object"}); + let output = json!({"type": "object", "properties": {"ok": {"type": "boolean"}}}); + let out = render_server("s", &[typed_tool("get-sum", &input, &output)]); + let typed = out.matches("Promise<{").count(); + assert_eq!(typed, 2, "both spellings should be typed:\n{out}"); + } + + /// The renderer's never-fail rule applies to output schemas too: a + /// construct it does not understand degrades rather than panicking. + #[test] + fn an_unrecognized_output_construct_degrades() { + let input = json!({"type": "object"}); + let output = json!({"not-a-real-keyword": ["whatever"]}); + let out = render_server("s", &[typed_tool("t", &input, &output)]); + assert!(out.contains("): Promise<"), "got:\n{out}"); + } + + /// A non-object output schema is still worth declaring. + #[test] + fn a_scalar_output_schema_is_declared() { + let input = json!({"type": "object"}); + let output = json!({"type": "string"}); + let out = render_server("s", &[typed_tool("name", &input, &output)]); + assert!(out.contains("): Promise;"), "got:\n{out}"); + } + + /// A tool taking nothing should not force the model to pass `{}`. + #[test] + fn tool_without_properties_takes_no_argument() { + let out = render_server("s", &[tool("ping", &json!({"type": "object"}))]); + assert!(out.contains("ping(): Promise;"), "got:\n{out}"); + } + + #[test] + fn tool_with_only_optional_properties_takes_an_optional_argument() { + let schema = json!({ + "type": "object", + "properties": {"limit": {"type": "integer"}}, + }); + let out = render_server("s", &[tool("list", &schema)]); + assert!(out.contains("list(params?: {"), "got:\n{out}"); + } + + #[test] + fn missing_input_schema_takes_no_argument() { + let out = render_server( + "s", + &[ToolDecl { + name: "t", + description: None, + input_schema: None, + output_schema: None, + }], + ); + assert!(out.contains("t(): Promise;"), "got:\n{out}"); + } + + // -- naming -- + + /// The protocol's own reference server names 12 of its 13 tools with + /// hyphens, so this is the common case, not an edge case. + #[test] + fn hyphenated_tool_gets_both_spellings() { + let out = render_server("s", &[tool("get-sum", &json!({}))]); + assert!( + out.contains(r#" "get-sum"(): Promise;"#), + "the wire name must stay callable, got:\n{out}" + ); + assert!( + out.contains(" get_sum(): Promise;"), + "a dotted alias should also work, got:\n{out}" + ); + assert!(out.contains("/** Alias for get-sum. */"), "got:\n{out}"); + } + + /// The alias carries only its cross-reference; repeating the description + /// would print it twice for one tool. + #[test] + fn alias_does_not_repeat_the_description() { + let out = render_server( + "s", + &[ToolDecl { + name: "get-sum", + description: Some("Adds numbers"), + input_schema: None, + output_schema: None, + }], + ); + assert_eq!( + out.matches("Adds numbers").count(), + 1, + "description should appear once, got:\n{out}" + ); + assert!(out.contains("/** Alias for get-sum. */"), "got:\n{out}"); + } + + #[test] + fn identifier_tool_is_not_aliased() { + let out = render_server("s", &[tool("query", &json!({}))]); + assert_eq!(out.matches("Promise").count(), 1, "got:\n{out}"); + assert!(!out.contains('"'), "no quoting needed, got:\n{out}"); + } + + #[test] + fn leading_digit_name_is_prefixed() { + let out = render_server("s", &[tool("2fa", &json!({}))]); + assert!(out.contains(r#""2fa"()"#), "got:\n{out}"); + assert!(out.contains("_2fa()"), "got:\n{out}"); + } + + /// Two wire names that sanitize to the same identifier must both survive. + #[test] + fn colliding_aliases_are_disambiguated() { + let out = render_server( + "s", + &[tool("get-sum", &json!({})), tool("get.sum", &json!({}))], + ); + assert!(out.contains("get_sum("), "got:\n{out}"); + assert!(out.contains("get_sum_2("), "got:\n{out}"); + assert!(out.contains(r#""get.sum"("#), "got:\n{out}"); + } + + #[test] + fn server_name_is_sanitized() { + let out = render_server("sea-orm", &[tool("t", &json!({}))]); + assert!(out.starts_with("declare const sea_orm: {"), "got:\n{out}"); + } + + /// The method names in a rendered declaration block. + fn declared_keys(out: &str) -> Vec { + out.lines() + .filter_map(|line| line.trim().strip_suffix("): Promise;")) + .filter_map(|line| line.split('(').next()) + .map(|key| key.trim_matches('"').to_string()) + .collect() + } + + #[test] + fn an_identifier_name_needs_no_alias() { + let table = binding_table(["query"]); + assert_eq!(table[0].keys, vec!["query".to_string()]); + } + + #[test] + fn a_hyphenated_name_is_bound_under_both_spellings() { + let table = binding_table(["get-sum"]); + assert_eq!( + table[0].keys, + vec!["get-sum".to_string(), "get_sum".to_string()] + ); + } + + /// `get-sum` sanitizes to `get_sum`, which another tool already owns. + #[test] + fn a_colliding_alias_never_shadows_a_real_tool() { + let table = binding_table(["get-sum", "get_sum"]); + + assert_eq!(table[0].wire_name, "get-sum"); + assert_eq!( + table[0].keys, + vec!["get-sum".to_string(), "get_sum_2".to_string()], + "the alias must yield to the tool that owns the name" + ); + assert_eq!(table[1].wire_name, "get_sum"); + assert_eq!( + table[1].keys, + vec!["get_sum".to_string()], + "a real tool keeps its own spelling" + ); + + let mut keys: Vec<&String> = table.iter().flat_map(|b| &b.keys).collect(); + let before = keys.len(); + keys.sort(); + keys.dedup(); + assert_eq!(before, keys.len(), "every key must be distinct: {keys:?}"); + } + + /// A name the model is shown must be one dispatch binds. + #[test] + fn declared_names_are_the_bound_names() { + let schema = json!({}); + let tools = [ + tool("get-sum", &schema), + tool("get_sum", &schema), + tool("query", &schema), + ]; + let out = render_server("s", &tools); + + let bound: Vec = binding_table(tools.iter().map(|t| t.name)) + .into_iter() + .flat_map(|b| b.keys) + .collect(); + + assert_eq!(declared_keys(&out), bound, "got:\n{out}"); + } + + /// Two members of the same name is a TypeScript error (TS2300). + #[test] + fn a_repeated_wire_name_is_declared_once() { + let schema = json!({}); + let tools = [tool("get-sum", &schema), tool("get-sum", &schema)]; + let out = render_server("s", &tools); + + assert_eq!(binding_table(tools.iter().map(|t| t.name)).len(), 1); + assert_eq!( + declared_keys(&out), + vec!["get-sum".to_string(), "get_sum".to_string()], + "got:\n{out}" + ); + } + + // -- documentation and shared types -- + + #[test] + fn renders_tool_description_as_jsdoc() { + let out = render_server( + "s", + &[ToolDecl { + name: "t", + description: Some("Does a\nthing"), + input_schema: None, + output_schema: None, + }], + ); + assert!(out.contains("/** Does a thing */"), "got:\n{out}"); + } + + /// Named types are hoisted above the object so several tools can share + /// them. + #[test] + fn named_types_are_emitted_once_above_the_server() { + let schema = json!({ + "type": "object", + "properties": {"user": {"$ref": "#/$defs/User"}}, + "required": ["user"], + "$defs": {"User": {"type": "object", "properties": {"id": {"type": "string"}}}}, + }); + let out = render_server("s", &[tool("a", &schema), tool("b", &schema)]); + assert_eq!(out.matches("type User =").count(), 1, "got:\n{out}"); + assert!( + out.find("type User =") < out.find("declare const s"), + "types must precede the server object, got:\n{out}" + ); + } + + // -- tolerant key resolution -- + + fn table(names: &[&str]) -> Vec { + binding_table(names.iter().copied()) + } + + fn resolved<'a>(table: &'a [ToolBinding], key: &str) -> &'a str { + match resolve_key(table, key) { + KeyMatch::One(binding) => binding.wire_name.as_str(), + KeyMatch::Ambiguous(names) => panic!("ambiguous: {names:?}"), + KeyMatch::None => panic!("`{key}` did not resolve"), + } + } + + #[test] + fn a_declared_spelling_resolves_to_its_own_tool() { + let t = table(&["create_entities", "get-sum"]); + assert_eq!(resolved(&t, "create_entities"), "create_entities"); + assert_eq!(resolved(&t, "get-sum"), "get-sum"); + assert_eq!(resolved(&t, "get_sum"), "get-sum"); + } + + #[test] + fn a_camel_case_key_reaches_a_snake_case_tool() { + let t = table(&["create_entities"]); + assert_eq!(resolved(&t, "createEntities"), "create_entities"); + } + + #[test] + fn a_camel_case_key_reaches_a_kebab_case_tool() { + let t = table(&["get-annotated-message"]); + assert_eq!(resolved(&t, "getAnnotatedMessage"), "get-annotated-message"); + } + + #[test] + fn punctuation_and_case_are_both_ignored() { + let t = table(&["browser_console_messages"]); + for key in [ + "browserConsoleMessages", + "browser-console-messages", + "BrowserConsoleMessages", + "BROWSER_CONSOLE_MESSAGES", + ] { + assert_eq!(resolved(&t, key), "browser_console_messages", "key: {key}"); + } + } + + /// A server exposing two spellings as separate tools must keep them apart, + /// so an exact hit is never diverted by the tolerant fallback. + #[test] + fn an_exact_match_wins_over_a_normalized_one() { + let t = table(&["read_file", "readFile"]); + assert_eq!(resolved(&t, "read_file"), "read_file"); + assert_eq!(resolved(&t, "readFile"), "readFile"); + } + + /// With no exact hit, two tools that collapse together are refused rather + /// than guessed between. + #[test] + fn colliding_spellings_are_refused() { + let t = table(&["read_file", "readFile"]); + match resolve_key(&t, "read-file") { + KeyMatch::Ambiguous(names) => { + assert!(names.contains(&"read_file"), "got {names:?}"); + assert!(names.contains(&"readFile"), "got {names:?}"); + } + other => panic!( + "expected ambiguity, got {}", + match other { + KeyMatch::One(b) => format!("One({})", b.wire_name), + KeyMatch::None => "None".to_string(), + KeyMatch::Ambiguous(_) => unreachable!(), + } + ), + } + } + + #[test] + fn an_unrelated_key_does_not_resolve() { + let t = table(&["create_entities"]); + assert!(matches!(resolve_key(&t, "deleteEntities"), KeyMatch::None)); + } + + /// Only what the table carries is reachable, so a tool filtered out of the + /// visible set cannot be summoned by an alternate spelling. + #[test] + fn a_tool_absent_from_the_table_is_unreachable() { + let t = table(&["read_file"]); + assert!(matches!(resolve_key(&t, "writeFile"), KeyMatch::None)); + assert!(matches!(resolve_key(&t, "write_file"), KeyMatch::None)); + } + + #[test] + fn a_key_with_no_alphanumerics_does_not_resolve() { + let t = table(&["read_file"]); + assert!(matches!(resolve_key(&t, "___"), KeyMatch::None)); + assert!(matches!(resolve_key(&t, ""), KeyMatch::None)); + } + + /// The sanitized alias of a hyphenated tool is a real declared key, so it + /// resolves exactly rather than through the fallback. + #[test] + fn a_sanitized_alias_still_resolves_to_its_tool() { + let t = table(&["migrate-status"]); + assert_eq!(resolved(&t, "migrate_status"), "migrate-status"); + assert_eq!(resolved(&t, "migrateStatus"), "migrate-status"); + } +} diff --git a/src/mcp/dispatch.rs b/src/mcp/dispatch.rs new file mode 100644 index 00000000..fe032b6f --- /dev/null +++ b/src/mcp/dispatch.rs @@ -0,0 +1,434 @@ +//! Reaching backing servers from inside the sandbox. +//! +//! A script calls `await sqlx.query({...})`. That name is a proxy installed +//! by the host; a property lookup yields a Rust closure that hands the call +//! to whoever is driving the sandbox and waits for the answer. +//! +//! The call crosses a runtime boundary. The engine runs on its own thread +//! with its own current-thread runtime, while backing servers are child +//! processes owned by the main runtime, and their I/O can only be polled +//! there. A channel is what keeps each on the runtime it belongs to. +//! +//! Namespaces are built through the object API rather than by generating +//! JavaScript, so server and tool names — which come from plugin manifests — +//! never reach a code position. +//! +//! Nothing is known about a server until a script names one of its tools, so +//! a script does not wait on servers it never mentions. + +use rquickjs::function::{Async, Constructor, Opt}; +use rquickjs::{CatchResultExt, Ctx, Function, Object}; +use serde_json::Value; +use tokio::sync::{mpsc, oneshot}; + +/// A tool call made by a script, waiting on its result. +#[derive(Debug)] +pub struct ToolCall { + /// Server name as declared by the plugin, not the sanitized spelling. + pub server: String, + /// The property name the script used. May be a sanitized alias; the host + /// resolves it against the server's tool list. + pub tool: String, + /// The single argument the script passed, or null. + pub args: Value, + pub reply: oneshot::Sender>, +} + +/// Channel a sandbox sends its tool calls to. +pub type CallSender = mpsc::UnboundedSender; +/// The receiving half, serviced by whoever drives the sandbox. +pub type CallReceiver = mpsc::UnboundedReceiver; + +pub fn channel() -> (CallSender, CallReceiver) { + mpsc::unbounded_channel() +} + +/// One backing server as the script sees it. +#[derive(Debug, Clone)] +pub struct Namespace { + /// Global the object is installed on. + pub key: String, + /// Server name to send with each call. + pub server: String, +} + +/// Install one global per namespace. +/// +/// A proxy answers any property with a callable, so nothing about a server +/// need be known here. The cost is that a lookup cannot say whether a tool +/// exists; the host decides that when the call arrives. +pub fn install<'js>( + ctx: &Ctx<'js>, + namespaces: &[Namespace], + calls: &CallSender, +) -> Result<(), String> { + let proxy: Constructor = ctx + .globals() + .get("Proxy") + .catch(ctx) + .map_err(|e| e.to_string())?; + + for namespace in namespaces { + let target = Object::new(ctx.clone()) + .catch(ctx) + .map_err(|e| e.to_string())?; + + let handler = Object::new(ctx.clone()) + .catch(ctx) + .map_err(|e| e.to_string())?; + handler + .set("get", get_trap(ctx, &namespace.server, calls)?) + .catch(ctx) + .map_err(|e| e.to_string())?; + + let installed: Object = proxy + .construct((target, handler)) + .catch(ctx) + .map_err(|e| e.to_string())?; + + ctx.globals() + .set(namespace.key.as_str(), installed) + .catch(ctx) + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// Property names the proxy must not answer to. +/// +/// `then` is why this exists: the engine looks for it on anything awaited, so +/// answering makes the namespace a thenable and `await sqlx` dispatches a +/// tool call named `then`. The rest the runtime reaches for on its own. +fn is_reserved_property(key: &str) -> bool { + matches!( + key, + "then" + | "catch" + | "finally" + | "constructor" + | "prototype" + | "__proto__" + | "toJSON" + | "toString" + | "valueOf" + | "inspect" + ) +} + +fn get_trap<'js>( + ctx: &Ctx<'js>, + server: &str, + calls: &CallSender, +) -> Result, String> { + let server = server.to_string(); + let calls = calls.clone(); + + Function::new( + ctx.clone(), + move |ctx: Ctx<'js>, + _target: rquickjs::Value<'js>, + property: rquickjs::Value<'js>| + -> rquickjs::Result> { + let undefined = rquickjs::Value::new_undefined(ctx.clone()); + + // A symbol key is the runtime asking about the object itself. + let Some(name) = property.as_string() else { + return Ok(undefined); + }; + let key = name.to_string()?; + if is_reserved_property(&key) { + return Ok(undefined); + } + + let function = tool_function(&ctx, &server, &key, &calls) + .map_err(|e| rquickjs::Exception::throw_message(&ctx, &e))?; + Ok(function.into_value()) + }, + ) + .catch(ctx) + .map_err(|e| e.to_string()) +} + +fn tool_function<'js>( + ctx: &Ctx<'js>, + server: &str, + tool: &str, + calls: &CallSender, +) -> Result, String> { + let server = server.to_string(); + let tool = tool.to_string(); + let calls = calls.clone(); + + Function::new( + ctx.clone(), + Async(move |ctx: Ctx<'js>, args: Opt>| { + let server = server.clone(); + let tool = tool.clone(); + let calls = calls.clone(); + async move { + let args = match args.0 { + Some(value) => to_json(&ctx, value)?, + None => Value::Null, + }; + + let (reply, answer) = oneshot::channel(); + calls + .send(ToolCall { + server: server.clone(), + tool: tool.clone(), + args, + reply, + }) + .map_err(|_| throw(&ctx, "tool dispatch is no longer available"))?; + + // A dropped sender means the caller abandoned the script; + // surfacing it as an exception lets the script's own error + // handling run. + let result = answer + .await + .map_err(|_| throw(&ctx, &format!("{server}.{tool} did not answer")))?; + + // A failing tool throws, so a script uses ordinary try/catch + // rather than inspecting a result shape. + let value = result.map_err(|message| throw(&ctx, &message))?; + from_json(&ctx, &value) + } + }), + ) + .catch(ctx) + .map_err(|e| e.to_string()) +} + +fn throw(ctx: &Ctx<'_>, message: &str) -> rquickjs::Error { + rquickjs::Exception::throw_message(ctx, message) +} + +/// Convert a JavaScript value to JSON through the engine's own serializer. +fn to_json<'js>(ctx: &Ctx<'js>, value: rquickjs::Value<'js>) -> rquickjs::Result { + if value.is_undefined() || value.is_null() { + return Ok(Value::Null); + } + let Some(encoded) = ctx.json_stringify(value)? else { + return Ok(Value::Null); + }; + let text = encoded.to_string()?; + serde_json::from_str(&text).map_err(|_| rquickjs::Error::Unknown) +} + +/// Convert JSON back into a JavaScript value. +fn from_json<'js>(ctx: &Ctx<'js>, value: &Value) -> rquickjs::Result> { + ctx.json_parse(value.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mcp::sandbox::{Limits, Sandbox}; + use serde_json::json; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + fn namespace(server: &str) -> Namespace { + Namespace { + key: server.to_string(), + server: server.to_string(), + } + } + + /// Drive a script, answering every tool call with `responder`, and record + /// what was asked. + async fn run_with( + source: &str, + namespaces: Vec, + responder: impl Fn(&ToolCall) -> Result + Send + 'static, + ) -> (Result, Vec<(String, String, Value)>) { + let (calls, mut receiver) = channel(); + let seen = Arc::new(Mutex::new(Vec::new())); + + let recorded = Arc::clone(&seen); + let pump = tokio::spawn(async move { + while let Some(call) = receiver.recv().await { + recorded.lock().unwrap().push(( + call.server.clone(), + call.tool.clone(), + call.args.clone(), + )); + let answer = responder(&call); + let _ = call.reply.send(answer); + } + }); + + let sandbox = Sandbox::new(Limits { + timeout: Duration::from_secs(5), + ..Limits::default() + }); + let outcome = match sandbox.run_script_with(source, &namespaces, calls).await { + Ok(o) => o.value().map_err(|e| e.to_string()), + Err(e) => Err(e.to_string()), + }; + + pump.await.unwrap(); + let asked = seen.lock().unwrap().clone(); + (outcome, asked) + } + + #[tokio::test] + async fn script_calls_a_tool_and_receives_its_result() { + let (out, asked) = run_with( + r#"await sqlx.query({ sql: "SELECT 1" })"#, + vec![namespace("sqlx")], + |_| Ok(json!({"rows": [{"n": 1}]})), + ) + .await; + + assert_eq!(out.unwrap(), json!({"rows": [{"n": 1}]})); + assert_eq!( + asked, + vec![( + "sqlx".to_string(), + "query".to_string(), + json!({"sql": "SELECT 1"}) + )] + ); + } + + /// The point of running code rather than proxying one call at a time: + /// several calls, and the filtering between them, happen without the + /// intermediate results ever leaving the sandbox. + #[tokio::test] + async fn script_composes_several_calls() { + let (out, asked) = run_with( + r#" + const all = await sqlx.query({ sql: "SELECT" }); + const keep = all.rows.filter(r => r.n > 1); + const out = []; + for (const row of keep) { + out.push(await sqlx.explain({ n: row.n })); + } + return out; + "#, + vec![namespace("sqlx")], + |call| match call.tool.as_str() { + "query" => Ok(json!({"rows": [{"n": 1}, {"n": 2}, {"n": 3}]})), + _ => Ok(json!({"plan": call.args["n"]})), + }, + ) + .await; + + assert_eq!(out.unwrap(), json!([{"plan": 2}, {"plan": 3}])); + assert_eq!(asked.len(), 3, "one query and two explains"); + } + + #[tokio::test] + async fn tool_failure_throws_into_the_script() { + let (out, _) = run_with( + r#" + try { + await sqlx.query({}); + return "no throw"; + } catch (e) { + return "caught: " + e.message; + } + "#, + vec![namespace("sqlx")], + |_| Err("table not found".to_string()), + ) + .await; + + assert_eq!(out.unwrap(), json!("caught: table not found")); + } + + /// An uncaught tool failure ends the script rather than resolving to a + /// value the model might mistake for success. + #[tokio::test] + async fn uncaught_tool_failure_fails_the_script() { + let (out, _) = run_with(r#"await sqlx.query({})"#, vec![namespace("sqlx")], |_| { + Err("boom".to_string()) + }) + .await; + + let err = out.unwrap_err(); + assert!(err.contains("boom"), "got: {err}"); + } + + #[tokio::test] + async fn calling_without_arguments_sends_null() { + let (out, asked) = run_with("await clock.now()", vec![namespace("clock")], |_| { + Ok(json!(123)) + }) + .await; + + assert_eq!(out.unwrap(), json!(123)); + assert_eq!(asked[0].2, Value::Null); + } + + /// Both spellings reach the host as written; mapping an alias to its + /// wire name needs the tool list, so it happens where that list is. + #[tokio::test] + async fn both_spellings_reach_the_host_as_written() { + let (out, asked) = run_with( + r#" + const a = await sqlx["migrate-status"](); + const b = await sqlx.migrate_status(); + return [a, b]; + "#, + vec![namespace("sqlx")], + |_| Ok(json!("ok")), + ) + .await; + + assert_eq!(out.unwrap(), json!(["ok", "ok"])); + let keys: Vec<&str> = asked.iter().map(|(_, tool, _)| tool.as_str()).collect(); + assert_eq!(keys, vec!["migrate-status", "migrate_status"]); + } + + /// A namespace that answers `then` becomes a thenable, and `await sqlx` + /// dispatches a tool call named `then` instead of resolving. + #[tokio::test] + async fn a_namespace_is_not_a_thenable() { + let (out, asked) = run_with( + "const v = await sqlx; return typeof v;", + vec![namespace("sqlx")], + |_| Ok(json!("ok")), + ) + .await; + + assert_eq!(out.unwrap(), json!("object")); + assert!(asked.is_empty(), "awaiting a namespace called: {asked:?}"); + } + + /// A name the script invents still reaches the host, which is where it + /// can be checked against the real tool list. + #[tokio::test] + async fn an_unknown_name_reaches_the_host() { + let (_, asked) = run_with( + "try { await sqlx.nonexistent(); } catch (e) {} return 1;", + vec![namespace("sqlx")], + |_| Err("no such tool".to_string()), + ) + .await; + + assert_eq!(asked.len(), 1); + assert_eq!(asked[0].1, "nonexistent"); + } + + #[tokio::test] + async fn several_servers_are_separate_namespaces() { + let (out, asked) = run_with( + "return [await a.ping(), await b.ping()];", + vec![namespace("a"), namespace("b")], + |call| Ok(json!(call.server)), + ) + .await; + + assert_eq!(out.unwrap(), json!(["a", "b"])); + assert_eq!(asked.len(), 2); + } + + /// Nothing beyond the declared namespaces appears. + #[tokio::test] + async fn undeclared_servers_are_absent() { + let (out, _) = run_with("typeof other", vec![namespace("sqlx")], |_| Ok(Value::Null)).await; + assert_eq!(out.unwrap(), json!("undefined")); + } +} diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs new file mode 100644 index 00000000..0a9fede8 --- /dev/null +++ b/src/mcp/mod.rs @@ -0,0 +1,23 @@ +//! The MCP meta-server. +//! +//! Symposium registers a single MCP server with each configured agent. That +//! server exposes the tools of every applicable plugin MCP server, instead of +//! each plugin being registered separately. +//! +//! See the [MCP meta-server RFD](../../md/rfds/mcp-meta-server/README.md). + +pub mod catalog; +pub mod client; +pub mod console; +pub mod declarations; +pub mod dispatch; +pub mod normalize; +pub mod resolve; +pub mod sandbox; +pub mod schema_to_ts; +pub mod server; +pub mod supervisor; +pub mod validate; + +#[cfg(test)] +mod corpus_tests; diff --git a/src/mcp/normalize.rs b/src/mcp/normalize.rs new file mode 100644 index 00000000..a24cbe4e --- /dev/null +++ b/src/mcp/normalize.rs @@ -0,0 +1,271 @@ +//! Turning model-written source into something the engine can evaluate. +//! +//! A model handed TypeScript declarations tends to reply with TypeScript, or +//! with a fenced code block, or with a bare statement body that has no value. +//! QuickJS accepts none of those. Without this step the feature fails on the +//! first script anyone writes. +//! +//! Two forms have to work and cannot be told apart without parsing: +//! +//! ```text +//! await sqlx.query({ sql: "..." }) // an expression, whose value is the result +//! const r = await sqlx.query(...); // a statement body, whose value is its `return` +//! return r.rows; +//! ``` +//! +//! Rather than parse JavaScript in Rust, the generated program tries the +//! expression form and falls back to the statement form, using `new Function` +//! to compile each without running it. The engine's own parser decides. + +/// Global the source is handed over on. +/// +/// The program reads it once and deletes it, so a script never sees it. +pub const SOURCE_GLOBAL: &str = "__symposiumSource"; + +/// The program that runs a model-written script. +/// +/// Deliberately a constant with no interpolation. The source arrives as a +/// JavaScript value set by the host, not as text formatted into this string, +/// so there is no position in which a caller-supplied value could become +/// code. Keeping it `&'static str` means adding one later cannot be done +/// quietly — the type has to change first. +pub const PROGRAM: &str = r#"(async () => { + const __src = globalThis.__symposiumSource; + // Out of reach before any model-written code runs. + delete globalThis.__symposiumSource; + + let __run; + try { + // Concise body: the value is the expression itself, and `await` is in + // scope because the wrapper is async. + __run = new Function("return (async () => (" + __src + "\n))();"); + } catch (e1) { + try { + // Block body: the value is whatever the source returns. + __run = new Function("return (async () => {" + __src + "\n})();"); + } catch (e2) { + throw new SyntaxError( + "script did not parse as JavaScript (" + e2.message + "). " + + "Write plain JavaScript: no type annotations, interfaces, or generics." + ); + } + } + + // Settle first: the expression form may evaluate to a function the model + // meant to be called, as in `async () => { ... }`, and that is only + // visible once the wrapper's own promise resolves. + const __value = await __run(); + return typeof __value === "function" ? await __value() : __value; +})()"#; + +/// Remove the packaging a model puts around code: markdown fences and a +/// module export. +pub fn prepare(source: &str) -> String { + let mut text = strip_fence(source.trim()); + for prefix in ["export default ", "export default\n"] { + if let Some(rest) = text.strip_prefix(prefix) { + text = rest.trim().to_string(); + break; + } + } + // A module export is often written as a statement, leaving a stray + // terminator once the keyword is gone. + text.trim().trim_end_matches(';').trim().to_string() +} + +fn strip_fence(text: &str) -> String { + let Some(rest) = text.strip_prefix("```") else { + return text.to_string(); + }; + // The opening fence may carry a language tag, which runs to end of line. + let body = match rest.split_once('\n') { + Some((_lang, body)) => body, + // A fence with no newline has no code in it. + None => return String::new(), + }; + body.trim_end() + .strip_suffix("```") + .unwrap_or(body) + .trim() + .to_string() +} + +#[cfg(test)] +mod tests { + use crate::mcp::sandbox::{Limits, Sandbox}; + use serde_json::{Value, json}; + use std::time::Duration; + + /// Normalization is only meaningful through the engine, so the tests run + /// what they produce. + async fn run(source: &str) -> Result { + let sandbox = Sandbox::new(Limits { + timeout: Duration::from_secs(2), + ..Limits::default() + }); + sandbox + .run_script(source) + .await + .map_err(|e| e.to_string())? + .value() + .map_err(|e| e.to_string()) + } + + // -- the two forms -- + + #[tokio::test] + async fn evaluates_a_bare_expression() { + assert_eq!(run("1 + 1").await.unwrap(), json!(2)); + } + + #[tokio::test] + async fn evaluates_a_statement_body() { + let out = run("const a = 20; const b = 22; return a + b;") + .await + .unwrap(); + assert_eq!(out, json!(42)); + } + + /// A model told to write an async arrow writes one; it has to be called + /// rather than returned as a function. + #[tokio::test] + async fn calls_an_async_arrow() { + assert_eq!(run("async () => 42").await.unwrap(), json!(42)); + assert_eq!( + run("async () => { return { ok: true }; }").await.unwrap(), + json!({"ok": true}) + ); + } + + #[tokio::test] + async fn awaits_inside_either_form() { + assert_eq!( + run("await Promise.resolve(7)").await.unwrap(), + json!(7), + "expression form" + ); + assert_eq!( + run("const v = await Promise.resolve(7); return v * 2;") + .await + .unwrap(), + json!(14), + "statement form" + ); + } + + /// An object literal at the start of a statement body is a block, not a + /// value, so the expression form must be tried first. + #[tokio::test] + async fn object_literal_is_not_read_as_a_block() { + assert_eq!(run("({ a: 1 })").await.unwrap(), json!({"a": 1})); + } + + // -- packaging -- + + #[tokio::test] + async fn strips_fenced_code_blocks() { + for fenced in [ + "```\n1 + 1\n```", + "```js\n1 + 1\n```", + "```javascript\n1 + 1\n```", + "```typescript\n1 + 1\n```", + ] { + assert_eq!(run(fenced).await.unwrap(), json!(2), "failed on: {fenced}"); + } + } + + #[tokio::test] + async fn strips_export_default() { + assert_eq!(run("export default async () => 5").await.unwrap(), json!(5)); + } + + #[tokio::test] + async fn strips_a_fence_around_an_export() { + let out = run("```js\nexport default async () => {\n return 9;\n}\n```") + .await + .unwrap(); + assert_eq!(out, json!(9)); + } + + // -- failure -- + + /// The predicted failure: the model is shown TypeScript declarations and + /// replies in TypeScript, which QuickJS cannot parse. The message has to + /// say so, or the model has nothing to correct. + #[tokio::test] + async fn typescript_syntax_is_reported_clearly() { + let err = run("async (name: string) => name.length") + .await + .unwrap_err(); + assert!( + err.contains("no type annotations"), + "error should name the likely cause, got: {err}" + ); + } + + #[tokio::test] + async fn runtime_errors_propagate() { + let err = run(r#"throw new Error("boom")"#).await.unwrap_err(); + assert!(err.contains("boom"), "got: {err}"); + } + + /// An empty script is not an error; it simply has no value. + #[tokio::test] + async fn empty_source_yields_null() { + assert_eq!(run("").await.unwrap(), Value::Null); + assert_eq!(run("```js\n```").await.unwrap(), Value::Null); + } + + // -- embedding safety -- + + /// The wrapper's own bindings live in a closure. A `new Function` body + /// sees globals only, never the enclosing lexical scope, so a script + /// cannot read or overwrite them. + #[tokio::test] + async fn wrapper_locals_are_not_visible_to_the_script() { + for probe in ["typeof __src", "typeof __run", "typeof __value"] { + assert_eq!(run(probe).await.unwrap(), json!("undefined"), "{probe}"); + } + } + + /// These are legal raw in a JSON string but were illegal raw in a + /// JavaScript string literal until ES2019, so the embedding depends on + /// the engine accepting them. + #[tokio::test] + async fn line_separators_survive_embedding() { + let out = run("return \"a\u{2028}b\u{2029}c\";").await.unwrap(); + assert_eq!(out, json!("a\u{2028}b\u{2029}c")); + } + + /// A script can garble the wrapper's structure — it is concatenated into + /// a source string and compiled, which is the whole point. What matters + /// is that doing so crosses no boundary: the escaped code runs in the + /// same sandbox, with the same absence of host capabilities. + #[tokio::test] + async fn escaping_the_wrapper_reaches_no_capabilities() { + let out = run(r#"1)); globalThis.reached = typeof fetch; ((async () => (2"#).await; + match out { + Err(e) => assert!(e.contains("did not parse"), "got: {e}"), + Ok(_) => { + let probe = run("typeof fetch").await.unwrap(); + assert_eq!(probe, json!("undefined")); + } + } + } + + // -- escaping -- + + /// The source is embedded in the generated program as a string literal, + /// so quotes, backslashes and newlines in the model's code must survive. + #[tokio::test] + async fn source_containing_quotes_survives_embedding() { + let out = run(r#"return "he said \"hi\"\n";"#).await.unwrap(); + assert_eq!(out, json!("he said \"hi\"\n")); + } + + #[tokio::test] + async fn source_containing_a_template_literal_survives() { + let out = run("const n = 2; return `n is ${n}`;").await.unwrap(); + assert_eq!(out, json!("n is 2")); + } +} diff --git a/src/mcp/resolve.rs b/src/mcp/resolve.rs new file mode 100644 index 00000000..3ef749f9 --- /dev/null +++ b/src/mcp/resolve.rs @@ -0,0 +1,733 @@ +//! Deciding which backing servers a workspace makes available. +//! +//! The same predicate filtering that decides which skills install decides +//! which MCP servers are in scope, so a workspace only ever sees tools +//! belonging to crates it actually depends on. That conditionality is the +//! thing no MCP primitive can express, and it is why the meta-server exists. +//! +//! Nothing is started here. Resolution is a read of the plugin registry; +//! processes begin on first use. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use crate::plugins::{McpTransport, StdioCommand}; + +use crate::config::Symposium; +use crate::mcp::client::SpawnSpec; +use crate::mcp::server::{EXECUTE, LIST_TOOLS}; +use crate::plugins::McpServerOverrides; + +/// Where a server's executable comes from. +/// +/// An installation is carried as its definition rather than a path: acquiring +/// it means downloading or installing, and startup has to stay side-effect +/// free. The same shape the hook layer uses. +#[derive(Debug, Clone)] +pub enum ServerCommand { + Path(PathBuf), + Installation(Box), +} + +/// A backing server, ready to be started on demand. +#[derive(Debug, Clone)] +pub struct ResolvedServer { + pub name: String, + pub command: ServerCommand, + pub args: Vec, + pub env: Vec<(String, String)>, + pub cwd: Option, + pub startup_timeout: Duration, + /// Ceiling on one call to this server, already reconciled with the + /// user's script deadline. + pub tool_call_timeout: Duration, + pub enabled_tools: Option>, + pub disabled_tools: Option>, + /// Acquired before the server is first started, so a package-runner + /// download does not land on the first tool call. + pub requirements: Vec, +} + +impl ResolvedServer { + /// Acquire what this server needs and produce its spawn spec. + /// + /// Deferred to first use rather than done at resolve time: a client may + /// spawn a throwaway copy of the meta-server to probe it, and startup must + /// not download anything. + pub async fn spawn_spec(&self, sym: &Symposium) -> anyhow::Result { + // Dispatch-time acquisition serves the cache; the SessionStart prewarm + // is what forces a freshness check once per session. + let update = symposium_install::UpdateLevel::None; + + // Requirements first, so a warmed cache is in place before the command + // runs. A failure here only means the cost lands later. + for requirement in &self.requirements { + if let Err(e) = + crate::installation::acquire_installation(sym, requirement, None, None, update) + .await + { + tracing::warn!( + server = %self.name, + requirement = %requirement.name, + error = %e, + "failed to acquire mcp server requirement" + ); + } + } + + let (command, mut args) = match &self.command { + ServerCommand::Path(path) => (path.clone(), Vec::new()), + ServerCommand::Installation(installation) => { + let acquired = crate::installation::acquire_installation( + sym, + installation, + None, + None, + update, + ) + .await?; + let label = format!("mcp server `{}`", self.name); + match crate::installation::resolve_runnable(acquired, &label)? { + symposium_install::Runnable::Exec(path) => (path, Vec::new()), + // A script is run through a shell, as hooks are. + symposium_install::Runnable::Script(path) => ( + PathBuf::from("sh"), + vec![path.to_string_lossy().into_owned()], + ), + } + } + }; + args.extend(self.args.iter().cloned()); + + Ok(SpawnSpec { + name: self.name.clone(), + command, + args, + env: self.env.clone(), + cwd: self.cwd.clone(), + startup_timeout: self.startup_timeout, + }) + } + + /// Whether a plugin's filters let this tool through. + pub fn exposes(&self, tool: &str) -> bool { + if let Some(allow) = &self.enabled_tools { + return allow.iter().any(|t| t == tool); + } + if let Some(deny) = &self.disabled_tools { + return !deny.iter().any(|t| t == tool); + } + true + } +} + +/// What resolution produced, including what it had to refuse. +#[derive(Debug, Default)] +pub struct Resolution { + pub servers: Vec, + /// Servers that could not be used, and why. Reported rather than + /// swallowed: a server silently missing looks like a broken workspace. + pub rejected: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Rejection { + pub server: String, + pub reason: String, +} + +/// Resolve the servers applicable to the workspace containing `cwd`. +pub async fn resolve(sym: &Symposium, cwd: &Path) -> Resolution { + let deps = sym.workspace_deps(cwd); + if deps.load().is_none() { + // Outside a Rust workspace there is nothing to condition on. + return Resolution::default(); + } + resolve_with_deps(sym, &deps).await +} + +/// Resolve against a workspace resolver the caller already holds, so the hook +/// pipeline shares its one `cargo metadata` result with this pass. +pub async fn resolve_with_deps( + sym: &Symposium, + deps: &Arc, +) -> Resolution { + let Some(loaded) = deps.load().cloned() else { + return Resolution::default(); + }; + let registry = crate::plugins::load_registry_with_workspace(sym, Some(&loaded)).await; + + let dep_ids = crate::pm::workspace_dep_ids(sym, deps).await; + let used_names = sym.config.plugins.used_names_in(&loaded.root); + let mut ctx = crate::predicate::PredicateContext::new(&dep_ids).with_used_names(&used_names); + + // The same active set every other facet resolves over, so a crate-sourced + // plugin's servers are reachable exactly like a registry plugin's. + let pms = sym.package_managers(deps); + let active = + crate::plugins::active_plugins(sym, ®istry, &pms, Some(&loaded.root), &mut ctx).await; + + let mut entries: Vec = Vec::new(); + for plugin in &active { + if !plugin.applies(&mut ctx) { + continue; + } + for entry in plugin.plugin.applicable_mcp_entries(&mut ctx) { + entries.push(Candidate { + entry, + owner: plugin.plugin.name.clone(), + plugin: &plugin.plugin, + }); + } + } + + build(entries, &loaded.root, sym.config.mcp.script_timeout_secs) +} + +/// Acquire what applicable servers need, once per session. +/// +/// Two different intents, treated differently: +/// +/// * **`requirements`** are acquired eagerly. Declaring one *is* the author +/// saying "warm this up" — it is how a package-runner server avoids paying +/// its download on the first tool call. +/// * **`installation`** commands are only refreshed if already present, as +/// hooks are. Installing every declared server eagerly would fetch tools a +/// session may never touch. +/// +/// Best-effort throughout: a failure here only means the cost lands later. +pub async fn prewarm(sym: &Symposium, resolution: &Resolution) { + let update = symposium_install::UpdateLevel::Check; + + for server in &resolution.servers { + for requirement in &server.requirements { + if let Err(e) = + crate::installation::acquire_installation(sym, requirement, None, None, update) + .await + { + tracing::debug!( + server = %server.name, + requirement = %requirement.name, + error = %e, + "prewarm: requirement acquisition failed" + ); + } + } + + if let ServerCommand::Installation(installation) = &server.command { + if let Err(e) = + crate::installation::refresh_installation_if_present(sym, installation, None).await + { + tracing::debug!( + server = %server.name, + error = %e, + "prewarm: command refresh failed" + ); + } + } + } +} + +/// An applicable entry together with the plugin that declared it, whose +/// `[[installations]]` its `installation` and `requirements` name. +struct Candidate<'a> { + entry: &'a crate::plugins::PluginMcpServer, + owner: String, + plugin: &'a crate::plugins::Plugin, +} + +/// Turn applicable manifest entries into runnable servers. +/// +/// `root` anchors a relative `cwd`: a plugin author cannot know what +/// directory the agent was launched from. +fn build(entries: Vec>, root: &Path, script_timeout_secs: u64) -> Resolution { + let mut resolution = Resolution::default(); + // Which plugin claimed each name, so a clash can name both sides. + let mut claimed: Vec<(String, String)> = Vec::new(); + + for Candidate { + entry, + owner, + plugin, + } in entries + { + let name = entry.name.clone(); + + // The meta-server's own tools live in the same namespace as the + // servers it exposes; a backing server taking one would shadow it. + if name == LIST_TOOLS || name == EXECUTE { + resolution.rejected.push(Rejection { + server: name, + reason: format!("`{owner}` uses a name reserved by the meta-server"), + }); + continue; + } + + // First-wins would silently drop one plugin's server, and a warning + // on a stdio server's stderr is invisible. Refusing names both. + if let Some((_, first)) = claimed.iter().find(|(n, _)| *n == name) { + resolution.rejected.push(Rejection { + server: name.clone(), + reason: format!("declared by both `{first}` and `{owner}`"), + }); + continue; + } + + let McpTransport::Stdio(stdio) = &entry.transport else { + resolution.rejected.push(Rejection { + server: name, + reason: "only stdio servers are supported".to_string(), + }); + continue; + }; + let command = match &stdio.command { + StdioCommand::Path(path) => ServerCommand::Path(path.clone()), + StdioCommand::Installation(installation) => { + match plugin.get_installation(installation) { + Some(found) => ServerCommand::Installation(Box::new(found.clone())), + None => { + resolution.rejected.push(Rejection { + server: name, + reason: format!( + "`{owner}` names installation `{installation}`, which it does not declare" + ), + }); + continue; + } + } + } + }; + + // Named requirements have to exist too, or a warmup silently does + // nothing. + let mut requirements = Vec::new(); + let mut missing = None; + for requirement in &entry.requirements { + match plugin.get_installation(requirement) { + Some(found) => requirements.push(found.clone()), + None => { + missing = Some(requirement.clone()); + break; + } + } + } + if let Some(requirement) = missing { + resolution.rejected.push(Rejection { + server: name, + reason: format!( + "`{owner}` names requirement `{requirement}`, which it does not declare" + ), + }); + continue; + } + + claimed.push((name.clone(), owner)); + resolution.servers.push(ResolvedServer { + name: name.clone(), + command, + args: stdio.args.clone(), + env: stdio + .env + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect(), + cwd: stdio.cwd.as_ref().map(|dir| root.join(dir)), + startup_timeout: Duration::from_secs( + entry.overrides.startup_timeout_secs.unwrap_or(30), + ), + tool_call_timeout: call_timeout(&entry.overrides, script_timeout_secs), + enabled_tools: entry.overrides.enabled_tools.clone(), + disabled_tools: entry.overrides.disabled_tools.clone(), + requirements, + }); + } + + resolution.servers.sort_by(|a, b| a.name.cmp(&b.name)); + resolution +} + +/// Reconcile a plugin's call timeout with the user's script deadline. +/// +/// A plugin author cannot see the user's configuration, so an override +/// longer than the whole script budget is clamped rather than rejected — +/// refusing to load a server because a user lowered their own limit would +/// punish the wrong person. +fn call_timeout(overrides: &McpServerOverrides, script_timeout_secs: u64) -> Duration { + let requested = overrides.tool_call_timeout_secs.unwrap_or(60); + // Leave the script deadline strictly larger, or the call timeout could + // never fire. + let ceiling = script_timeout_secs.saturating_sub(1).max(1); + Duration::from_secs(requested.min(ceiling)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plugins::PluginMcpServer; + + fn stdio(name: &str) -> PluginMcpServer { + PluginMcpServer { + name: name.to_string(), + predicates: Default::default(), + overrides: McpServerOverrides::default(), + transport: McpTransport::Stdio(crate::plugins::StdioServer { + command: StdioCommand::Path("/usr/bin/true".into()), + args: Vec::new(), + env: Default::default(), + cwd: None, + }), + requirements: Vec::new(), + } + } + + /// A plugin carrying the given entries, so `installation` and + /// `requirements` have somewhere to resolve against. + fn owner_plugin(installations: Vec) -> crate::plugins::Plugin { + crate::plugins::Plugin { + name: "db-plugin".to_string(), + predicates: Default::default(), + installations, + hooks: Vec::new(), + skills: Vec::new(), + mcp_servers: Vec::new(), + subcommands: Default::default(), + custom_predicates: Vec::new(), + chained: Vec::new(), + requires_use: false, + } + } + + fn resolve_with( + entries: Vec<&PluginMcpServer>, + plugin: &crate::plugins::Plugin, + script_secs: u64, + ) -> Resolution { + build( + entries + .into_iter() + .map(|entry| Candidate { + entry, + owner: plugin.name.clone(), + plugin, + }) + .collect(), + Path::new("/ws"), + script_secs, + ) + } + + /// Entries paired with the plugin name that declared each, against a + /// plugin declaring no installations. + fn resolve_all(entries: Vec<(&PluginMcpServer, &str)>, script_secs: u64) -> Resolution { + let plugin = owner_plugin(Vec::new()); + build( + entries + .into_iter() + .map(|(entry, owner)| Candidate { + entry, + owner: owner.to_string(), + plugin: &plugin, + }) + .collect(), + Path::new("/ws"), + script_secs, + ) + } + + #[test] + fn relative_cwd_resolves_against_the_workspace_root() { + let mut entry = stdio("sqlx"); + if let McpTransport::Stdio(s) = &mut entry.transport { + s.cwd = Some("crates/db".into()); + } + let out = resolve_all(vec![(&entry, "db-plugin")], 120); + assert_eq!( + out.servers[0].cwd.as_deref(), + Some(Path::new("/ws/crates/db")), + "got: {:?}", + out.servers[0].cwd + ); + } + + #[test] + fn absolute_cwd_is_left_as_written() { + let mut entry = stdio("sqlx"); + if let McpTransport::Stdio(s) = &mut entry.transport { + s.cwd = Some("/opt/db".into()); + } + let out = resolve_all(vec![(&entry, "db-plugin")], 120); + assert_eq!(out.servers[0].cwd.as_deref(), Some(Path::new("/opt/db"))); + } + + #[test] + fn stdio_servers_become_spawnable() { + let entry = stdio("sqlx"); + let out = resolve_all(vec![(&entry, "db-plugin")], 120); + + assert_eq!(out.servers.len(), 1); + assert_eq!(out.servers[0].name, "sqlx"); + assert!(out.rejected.is_empty()); + } + + /// A silently missing server looks like a broken workspace, so refusals + /// are reported. + #[test] + fn http_servers_are_refused_with_a_reason() { + let entry = PluginMcpServer { + name: "remote".to_string(), + predicates: Default::default(), + overrides: McpServerOverrides::default(), + transport: McpTransport::Http(crate::plugins::RemoteServer { + url: "http://localhost:8080/mcp".to_string(), + headers: Default::default(), + }), + requirements: Vec::new(), + }; + let out = resolve_all(vec![(&entry, "p")], 120); + + assert!(out.servers.is_empty()); + assert_eq!(out.rejected.len(), 1); + assert!(out.rejected[0].reason.contains("stdio")); + } + + /// First-wins would drop one plugin's server silently, and a warning on + /// a stdio server's stderr is invisible. + #[test] + fn duplicate_names_are_refused_naming_both_plugins() { + let a = stdio("sqlx"); + let b = stdio("sqlx"); + let out = resolve_all(vec![(&a, "first-plugin"), (&b, "second-plugin")], 120); + + assert_eq!(out.servers.len(), 1, "the first still works"); + assert_eq!(out.rejected.len(), 1); + let reason = &out.rejected[0].reason; + assert!( + reason.contains("first-plugin") && reason.contains("second-plugin"), + "both sides should be named, got: {reason}" + ); + } + + /// A backing server called `execute` would shadow the meta-server's own + /// tool. + #[test] + fn reserved_names_are_refused() { + for name in [LIST_TOOLS, EXECUTE] { + let entry = stdio(name); + let out = resolve_all(vec![(&entry, "p")], 120); + assert!(out.servers.is_empty(), "{name} should be refused"); + assert!(out.rejected[0].reason.contains("reserved")); + } + } + + #[test] + fn per_server_timeouts_are_honored() { + let mut entry = stdio("slow"); + entry.overrides.startup_timeout_secs = Some(45); + entry.overrides.tool_call_timeout_secs = Some(90); + let out = resolve_all(vec![(&entry, "p")], 300); + + assert_eq!(out.servers[0].startup_timeout, Duration::from_secs(45)); + assert_eq!(out.servers[0].tool_call_timeout, Duration::from_secs(90)); + } + + /// A plugin author cannot see the user's configuration, so an override + /// beyond the script budget is clamped rather than refused. + #[test] + fn call_timeout_is_clamped_below_the_script_deadline() { + let mut entry = stdio("slow"); + entry.overrides.tool_call_timeout_secs = Some(600); + let out = resolve_all(vec![(&entry, "p")], 30); + + assert_eq!( + out.servers[0].tool_call_timeout, + Duration::from_secs(29), + "must stay strictly under the script deadline or it can never fire" + ); + } + + // -- installations -- + + fn installation(name: &str, install_commands: Vec) -> crate::plugins::Installation { + crate::plugins::Installation { + name: name.to_string(), + requirements: Vec::new(), + install_commands, + source: None, + executable: Some("/usr/bin/true".to_string()), + script: None, + args: Vec::new(), + } + } + + fn installation_backed( + name: &str, + installation: &str, + requirements: &[&str], + ) -> PluginMcpServer { + PluginMcpServer { + name: name.to_string(), + predicates: Default::default(), + overrides: McpServerOverrides::default(), + transport: McpTransport::Stdio(crate::plugins::StdioServer { + command: StdioCommand::Installation(installation.to_string()), + args: Vec::new(), + env: Default::default(), + cwd: None, + }), + requirements: requirements.iter().map(|r| r.to_string()).collect(), + } + } + + /// The definition is carried, not resolved: acquiring means downloading, + /// and that must not happen while resolving. + #[test] + fn an_installation_backed_server_carries_its_definition() { + let entry = installation_backed("sqlx", "sqlx-mcp", &[]); + let plugin = owner_plugin(vec![installation("sqlx-mcp", vec![])]); + let out = resolve_with(vec![&entry], &plugin, 120); + + assert_eq!(out.servers.len(), 1, "rejected: {:?}", out.rejected); + assert!(matches!( + out.servers[0].command, + ServerCommand::Installation(_) + )); + } + + #[test] + fn an_unknown_installation_is_refused_naming_it() { + let entry = installation_backed("sqlx", "missing", &[]); + let plugin = owner_plugin(Vec::new()); + let out = resolve_with(vec![&entry], &plugin, 120); + + assert!(out.servers.is_empty()); + assert!( + out.rejected[0].reason.contains("missing"), + "got: {:?}", + out.rejected + ); + } + + /// A warmup that names nothing would silently do nothing, so the + /// reference has to be checked. + #[test] + fn an_unknown_requirement_is_refused_naming_it() { + let entry = installation_backed("sqlx", "sqlx-mcp", &["not-declared"]); + let plugin = owner_plugin(vec![installation("sqlx-mcp", vec![])]); + let out = resolve_with(vec![&entry], &plugin, 120); + + assert!(out.servers.is_empty()); + assert!( + out.rejected[0].reason.contains("not-declared"), + "got: {:?}", + out.rejected + ); + } + + #[test] + fn requirements_are_carried_for_acquisition() { + let entry = installation_backed("sqlx", "sqlx-mcp", &["warmup"]); + let plugin = owner_plugin(vec![ + installation("sqlx-mcp", vec![]), + installation("warmup", vec!["true".to_string()]), + ]); + let out = resolve_with(vec![&entry], &plugin, 120); + + assert_eq!(out.servers.len(), 1, "rejected: {:?}", out.rejected); + assert_eq!(out.servers[0].requirements.len(), 1); + assert_eq!(out.servers[0].requirements[0].name, "warmup"); + } + + /// A declared requirement is acquired eagerly: that is the author asking + /// for a warm cache, and the alternative is the download landing on the + /// agent's first tool call. + #[tokio::test] + async fn prewarm_runs_declared_requirements() { + let tmp = tempfile::tempdir().unwrap(); + let marker = tmp.path().join("warmed"); + let sym = Symposium::from_dir(tmp.path()); + + let entry = installation_backed("sqlx", "sqlx-mcp", &["warmup"]); + let plugin = owner_plugin(vec![ + installation("sqlx-mcp", vec![]), + installation("warmup", vec![format!("touch {}", marker.display())]), + ]); + let resolution = resolve_with(vec![&entry], &plugin, 120); + + assert!(!marker.exists(), "resolving must not run anything"); + prewarm(&sym, &resolution).await; + assert!( + marker.exists(), + "the warmup should have run at prewarm time" + ); + } + + /// A warmup that fails only means the cost lands later, so it must not + /// take the session with it. + #[tokio::test] + async fn prewarm_survives_a_failing_requirement() { + let tmp = tempfile::tempdir().unwrap(); + let sym = Symposium::from_dir(tmp.path()); + + let entry = installation_backed("sqlx", "sqlx-mcp", &["warmup"]); + let plugin = owner_plugin(vec![ + installation("sqlx-mcp", vec![]), + installation("warmup", vec!["exit 1".to_string()]), + ]); + let resolution = resolve_with(vec![&entry], &plugin, 120); + + prewarm(&sym, &resolution).await; + } + + // -- tool filters -- + + #[test] + fn an_allow_list_hides_everything_else() { + let mut entry = stdio("sqlx"); + entry.overrides.enabled_tools = Some(vec!["query".into()]); + let out = resolve_all(vec![(&entry, "p")], 120); + + assert!(out.servers[0].exposes("query")); + assert!(!out.servers[0].exposes("drop_table")); + } + + #[test] + fn a_deny_list_hides_only_what_it_names() { + let mut entry = stdio("sqlx"); + entry.overrides.disabled_tools = Some(vec!["drop_table".into()]); + let out = resolve_all(vec![(&entry, "p")], 120); + + assert!(out.servers[0].exposes("query")); + assert!(!out.servers[0].exposes("drop_table")); + } + + /// An empty allow-list means nothing, which is different from declaring + /// no filter at all. + #[test] + fn an_empty_allow_list_exposes_nothing() { + let mut entry = stdio("sqlx"); + entry.overrides.enabled_tools = Some(vec![]); + let out = resolve_all(vec![(&entry, "p")], 120); + + assert!(!out.servers[0].exposes("query")); + } + + #[test] + fn without_filters_every_tool_is_exposed() { + let entry = stdio("sqlx"); + let out = resolve_all(vec![(&entry, "p")], 120); + assert!(out.servers[0].exposes("anything")); + } + + /// Order must not depend on registry iteration, or the inventory shown + /// to the model would shift between sessions. + #[test] + fn servers_are_ordered_by_name() { + let b = stdio("b-server"); + let a = stdio("a-server"); + let out = resolve_all(vec![(&b, "p"), (&a, "p")], 120); + + let names: Vec<&str> = out.servers.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names, vec!["a-server", "b-server"]); + } +} diff --git a/src/mcp/sandbox.rs b/src/mcp/sandbox.rs new file mode 100644 index 00000000..509715eb --- /dev/null +++ b/src/mcp/sandbox.rs @@ -0,0 +1,685 @@ +//! The JavaScript sandbox scripts run in. +//! +//! A script is model-written and untrusted in the sense that matters here: it +//! may loop forever, allocate without bound, or recurse until the stack gives +//! out. None of those may take the agent's session with them. +//! +//! Two properties do the work: +//! +//! * **Deny by construction.** A bare QuickJS context has no filesystem, +//! network, process or module loader — not because they are switched off, +//! but because nothing registers them. There is no allowlist to get wrong. +//! * **Two layers of deadline.** An interrupt handler stops a script spinning +//! in the interpreter; an outer timeout catches one blocked awaiting a host +//! call, where the interpreter is not running and the interrupt cannot fire. +//! Neither alone is sufficient. +//! +//! The interrupt raises an exception the script cannot catch, so +//! `try { while(true){} } catch(e) {}` still terminates. + +use std::time::{Duration, Instant}; + +use rquickjs::{AsyncContext, AsyncRuntime, CatchResultExt, Ctx}; +use serde::Serialize; +use serde_json::Value; + +use super::{console, dispatch, normalize}; + +/// Extra room beyond the JavaScript stack limit for the interpreter's own +/// frames. Without it a script that hits the JS limit would instead overflow +/// the OS thread stack, which is a crash rather than an exception. +const STACK_HEADROOM: usize = 1 << 20; + +/// How long each deadline layer waits past the one inside it. +/// +/// The interrupt produces a precise error, so give it a moment to win before +/// the blunter layers fire. +const OUTER_GRACE: Duration = Duration::from_millis(250); + +/// Bounds on one script execution. +#[derive(Debug, Clone, Copy)] +pub struct Limits { + pub timeout: Duration, + pub memory_bytes: usize, + pub stack_bytes: usize, + /// Ceiling on the serialized result. Exceeding it truncates rather than + /// fails: a script that already called tools should not lose its work. + pub max_result_bytes: usize, + /// Ceiling on captured console output. + pub max_console_bytes: usize, +} + +impl Default for Limits { + fn default() -> Self { + Self { + timeout: Duration::from_secs(120), + memory_bytes: 64 << 20, + stack_bytes: 1 << 20, + max_result_bytes: 32 << 10, + max_console_bytes: 8 << 10, + } + } +} + +/// Why a script produced no value. +/// +/// Serializes to a tagged object so the model can tell a limit it exceeded +/// from a mistake in its own code, and retry accordingly. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "error", rename_all = "snake_case")] +pub enum SandboxError { + ScriptTimeout { limit_secs: u64 }, + MemoryExhausted { limit_mb: u64 }, + ScriptError { message: String }, + Internal { message: String }, +} + +impl std::fmt::Display for SandboxError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ScriptTimeout { limit_secs } => { + write!(f, "script exceeded its {limit_secs}s deadline") + } + Self::MemoryExhausted { limit_mb } => { + write!(f, "script exceeded its {limit_mb}MB memory limit") + } + Self::ScriptError { message } | Self::Internal { message } => write!(f, "{message}"), + } + } +} + +impl std::error::Error for SandboxError {} + +/// What a script produced. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Outcome { + /// The result, serialized. A prefix of it when `truncated_from` is set. + pub json: String, + /// Byte length of the untruncated result, when it did not fit. + pub truncated_from: Option, + /// Console output, in the order it was written. + pub logs: Vec, + /// Whether logging stopped for want of budget. + pub logs_dropped: bool, +} + +impl Outcome { + /// Parse the result back, for callers that know it was not truncated. + pub fn value(&self) -> Result { + serde_json::from_str(&self.json).map_err(|e| SandboxError::Internal { + message: format!("result was not valid JSON: {e}"), + }) + } +} + +/// Runs one script under [`Limits`]. +#[derive(Debug, Clone, Copy, Default)] +pub struct Sandbox { + limits: Limits, +} + +impl Sandbox { + pub fn new(limits: Limits) -> Self { + Self { limits } + } + + /// Evaluate `script` and return its value as JSON. + /// + /// The engine runs on its own thread: QuickJS is not `Send`, and the + /// thread lets the stack be sized against the configured limit. A fresh + /// runtime per call means no state survives from one script to the next. + pub async fn eval(&self, script: &str) -> Result { + self.eval_inner(script, None, Vec::new(), None) + .await? + .value() + } + + /// Run a model-written script. + /// + /// The source is handed to the engine as a value rather than spliced into + /// program text. + pub async fn run_script(&self, source: &str) -> Result { + self.run_script_with(source, &[], dispatch::channel().0) + .await + } + + /// Run a script with backing servers in scope. + pub async fn run_script_with( + &self, + source: &str, + namespaces: &[dispatch::Namespace], + calls: dispatch::CallSender, + ) -> Result { + self.eval_inner( + normalize::PROGRAM, + Some(&normalize::prepare(source)), + namespaces.to_vec(), + Some(calls), + ) + .await + } + + async fn eval_inner( + &self, + script: &str, + source: Option<&str>, + namespaces: Vec, + calls: Option, + ) -> Result { + let limits = self.limits; + let script = script.to_string(); + let source = source.map(str::to_string); + let (tx, rx) = tokio::sync::oneshot::channel(); + + let spawned = std::thread::Builder::new() + .name("mcp-sandbox".to_string()) + .stack_size(limits.stack_bytes + STACK_HEADROOM) + .spawn(move || { + let outcome = match tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + { + // The interrupt only fires while the interpreter runs, so + // a script parked on an unresolved promise has nothing to + // interrupt. Abandoning the future drops the runtime, and + // with it the tool-call sender the caller's pump awaits. + Ok(rt) => rt.block_on(async { + let bounded = tokio::time::timeout( + limits.timeout + OUTER_GRACE, + run( + &script, + source.as_deref(), + &namespaces, + calls.as_ref(), + limits, + ), + ); + match bounded.await { + Ok(outcome) => outcome, + Err(_) => Err(SandboxError::ScriptTimeout { + limit_secs: limits.timeout.as_secs(), + }), + } + }), + Err(e) => Err(SandboxError::Internal { + message: format!("could not start sandbox runtime: {e}"), + }), + }; + // A closed receiver means the outer deadline already fired. + let _ = tx.send(outcome); + }); + + if let Err(e) = spawned { + return Err(SandboxError::Internal { + message: format!("could not start sandbox thread: {e}"), + }); + } + + // Backstop for a thread that died without reporting. Given room to + // lose the race, so the precise error above wins. + match tokio::time::timeout(limits.timeout + OUTER_GRACE * 2, rx).await { + Ok(Ok(outcome)) => outcome, + Ok(Err(_)) => Err(SandboxError::Internal { + message: "sandbox thread ended without reporting".to_string(), + }), + Err(_) => Err(SandboxError::ScriptTimeout { + limit_secs: limits.timeout.as_secs(), + }), + } + } +} + +async fn run( + script: &str, + source: Option<&str>, + namespaces: &[dispatch::Namespace], + calls: Option<&dispatch::CallSender>, + limits: Limits, +) -> Result { + let runtime = AsyncRuntime::new().map_err(internal)?; + runtime.set_memory_limit(limits.memory_bytes).await; + runtime.set_max_stack_size(limits.stack_bytes).await; + + // The inner layer. Fires while the interpreter is running, and raises an + // exception the script cannot catch. + let deadline = Instant::now() + limits.timeout; + runtime + .set_interrupt_handler(Some(Box::new(move || Instant::now() >= deadline))) + .await; + + let log = console::Log::shared(limits.max_console_bytes); + let context = AsyncContext::full(&runtime).await.map_err(internal)?; + let outcome = AsyncContext::async_with(&context, async |ctx| { + evaluate(ctx, script, source, namespaces, calls, &log).await + }) + .await; + + // Settle any promises the script left pending before deciding the result. + runtime.idle().await; + + let (logs, logs_dropped) = { + let log = log.borrow(); + (log.entries().to_vec(), log.dropped()) + }; + + let json = match outcome { + Ok(json) => json, + Err(message) => { + let allocated = runtime.memory_usage().await.malloc_size.max(0) as usize; + return Err(classify(message, deadline, limits, allocated)); + } + }; + + let (json, truncated_from) = truncate(json, limits.max_result_bytes); + Ok(Outcome { + json, + truncated_from, + logs, + logs_dropped, + }) +} + +/// Bound the serialized result. +/// +/// Truncating rather than rejecting keeps whatever the script achieved: it may +/// already have called tools with side effects, and an error would discard +/// that along with the data. The cut lands on a character boundary, so the +/// prefix is still valid text even though it is no longer valid JSON. +fn truncate(json: String, limit: usize) -> (String, Option) { + if json.len() <= limit { + return (json, None); + } + let original = json.len(); + let mut end = limit; + while end > 0 && !json.is_char_boundary(end) { + end -= 1; + } + let mut cut = json; + cut.truncate(end); + (cut, Some(original)) +} + +async fn evaluate<'js>( + ctx: Ctx<'js>, + script: &str, + source: Option<&str>, + namespaces: &[dispatch::Namespace], + calls: Option<&dispatch::CallSender>, + log: &console::Shared, +) -> Result { + console::install(&ctx, log)?; + if let Some(calls) = calls { + dispatch::install(&ctx, namespaces, calls)?; + } + + if let Some(source) = source { + ctx.globals() + .set(normalize::SOURCE_GLOBAL, source) + .catch(&ctx) + .map_err(|e| e.to_string())?; + } + + // Evaluated as a plain script rather than a module: scripts arrive + // normalized into a self-calling async function, so the value is already a + // promise and top-level `await` is never needed. + let value: rquickjs::Value<'js> = ctx + .eval(script.as_bytes().to_vec()) + .catch(&ctx) + .map_err(|e| e.to_string())?; + + // A script is normally an async function call, so the value is a promise. + let resolved = match value.as_promise() { + Some(promise) => promise + .clone() + .into_future::() + .await + .catch(&ctx) + .map_err(|e| e.to_string())?, + None => value, + }; + + to_json(&ctx, resolved) +} + +/// Convert a JavaScript value to JSON via the engine's own serializer, so +/// `toJSON` and nested structures behave as the script author expects. +fn to_json<'js>(ctx: &Ctx<'js>, value: rquickjs::Value<'js>) -> Result { + if value.is_undefined() { + return Ok("null".to_string()); + } + let encoded = ctx + .json_stringify(value) + .catch(ctx) + .map_err(|e| e.to_string())?; + let Some(encoded) = encoded else { + // `JSON.stringify` yields nothing for values with no JSON form. + return Ok("null".to_string()); + }; + encoded.to_string().map_err(|e| e.to_string()) +} + +/// Decide which limit, if any, a failure represents. +/// +/// QuickJS surfaces both an interrupt and an allocation failure as ordinary +/// exceptions, so the cause has to be recovered from the surrounding state +/// rather than the message. An exhausted heap in particular throws a null +/// value, because there is no memory left to build an error object with. +fn classify(message: String, deadline: Instant, limits: Limits, allocated: usize) -> SandboxError { + if Instant::now() >= deadline { + return SandboxError::ScriptTimeout { + limit_secs: limits.timeout.as_secs(), + }; + } + // Still holding most of the budget when the exception surfaced. + if allocated * 10 >= limits.memory_bytes * 8 { + return SandboxError::MemoryExhausted { + limit_mb: (limits.memory_bytes / (1 << 20)) as u64, + }; + } + SandboxError::ScriptError { message } +} + +fn internal(e: impl std::fmt::Display) -> SandboxError { + SandboxError::Internal { + message: e.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn fast() -> Limits { + Limits { + timeout: Duration::from_millis(300), + ..Limits::default() + } + } + + async fn eval(script: &str) -> Result { + Sandbox::new(fast()).eval(script).await + } + + #[tokio::test] + async fn evaluates_an_expression() { + assert_eq!(eval("1 + 1").await.unwrap(), json!(2)); + } + + #[tokio::test] + async fn returns_structured_values() { + let out = eval(r#"({ rows: [{ id: 1 }], ok: true })"#).await.unwrap(); + assert_eq!(out, json!({"rows": [{"id": 1}], "ok": true})); + } + + #[tokio::test] + async fn awaits_a_returned_promise() { + let out = eval("(async () => 42)()").await.unwrap(); + assert_eq!(out, json!(42)); + } + + #[tokio::test] + async fn undefined_becomes_null() { + assert_eq!(eval("undefined").await.unwrap(), Value::Null); + } + + // -- deadlines -- + + #[tokio::test] + async fn cpu_bound_loop_hits_the_deadline() { + let err = eval("while (true) {}").await.unwrap_err(); + assert_eq!(err, SandboxError::ScriptTimeout { limit_secs: 0 }); + } + + /// The interrupt raises an exception the script cannot catch. Were it + /// catchable, a model could sit inside `try`/`catch` and never yield. + #[tokio::test] + async fn script_cannot_catch_the_deadline() { + let err = + eval(r#"(() => { try { while (true) {} } catch (e) { return "swallowed"; } })()"#) + .await + .unwrap_err(); + assert!( + matches!(err, SandboxError::ScriptTimeout { .. }), + "deadline must not be catchable, got: {err:?}" + ); + } + + #[tokio::test] + async fn deadline_is_enforced_promptly() { + let started = Instant::now(); + let _ = eval("while (true) {}").await; + assert!( + started.elapsed() < Duration::from_secs(3), + "took {:?}", + started.elapsed() + ); + } + + /// Nothing to interrupt: the deadline is the only thing that can end it. + #[tokio::test] + async fn a_promise_that_never_settles_still_reports() { + let started = Instant::now(); + let err = Sandbox::new(fast()) + .run_script("return new Promise(() => {})") + .await + .unwrap_err(); + + assert!( + matches!(err, SandboxError::ScriptTimeout { .. }), + "got: {err:?}" + ); + assert!( + started.elapsed() < Duration::from_secs(3), + "took {:?}", + started.elapsed() + ); + } + + /// The engine thread owns the only tool-call sender, so the channel + /// closing is how a caller knows the thread went away. + #[tokio::test] + async fn a_wedged_script_releases_the_dispatch_channel() { + let (calls, mut receiver) = dispatch::channel(); + let err = Sandbox::new(fast()) + .run_script_with("return new Promise(() => {})", &[], calls) + .await + .unwrap_err(); + assert!( + matches!(err, SandboxError::ScriptTimeout { .. }), + "got: {err:?}" + ); + + let closed = tokio::time::timeout(Duration::from_secs(5), receiver.recv()) + .await + .expect("the sandbox thread must drop its sender, not hold it"); + assert!( + closed.is_none(), + "channel should be closed, got: {closed:?}" + ); + } + + /// An un-awaited call must not keep the engine past its deadline. + #[tokio::test] + async fn an_unawaited_tool_call_does_not_outlive_the_deadline() { + let (calls, _receiver) = dispatch::channel(); + let namespaces = vec![dispatch::Namespace { + key: "srv".to_string(), + server: "srv".to_string(), + }]; + + let started = Instant::now(); + // No `await`, and nothing is servicing the channel. + let err = Sandbox::new(fast()) + .run_script_with("srv.go({}); return 1;", &namespaces, calls) + .await + .unwrap_err(); + + assert!( + matches!(err, SandboxError::ScriptTimeout { .. }), + "got: {err:?}" + ); + assert!( + started.elapsed() < Duration::from_secs(3), + "took {:?}", + started.elapsed() + ); + } + + // -- memory -- + + /// `set_memory_limit` is inert under a custom allocator, and feature + /// unification means a transitive dependency could enable + /// `rquickjs/rust-alloc` invisibly. `MemoryExhausted` alone: a timeout + /// would also be reached with the limit inert. + #[tokio::test] + async fn allocation_is_bounded() { + let sandbox = Sandbox::new(Limits { + memory_bytes: 1 << 20, + ..fast() + }); + let err = sandbox + .eval("const a = []; while (true) { a.push(new Array(10000)); }") + .await + .unwrap_err(); + assert!( + matches!(err, SandboxError::MemoryExhausted { .. }), + "the memory limit must be what stops this, got: {err:?}" + ); + } + + /// Deep recursion must raise a JavaScript exception rather than overflow + /// the thread's own stack, which would abort the process. + #[tokio::test] + async fn recursion_is_bounded() { + let err = eval("(function f() { return f(); })()").await.unwrap_err(); + assert!( + matches!(err, SandboxError::ScriptError { .. }), + "got: {err:?}" + ); + } + + // -- errors -- + + #[tokio::test] + async fn script_errors_carry_their_message() { + let err = eval(r#"throw new Error("boom")"#).await.unwrap_err(); + let SandboxError::ScriptError { message } = &err else { + panic!("expected a script error, got: {err:?}"); + }; + assert!(message.contains("boom"), "got: {message}"); + } + + #[tokio::test] + async fn syntax_errors_are_reported() { + let err = eval("this is not javascript").await.unwrap_err(); + assert!( + matches!(err, SandboxError::ScriptError { .. }), + "got: {err:?}" + ); + } + + /// The model is told what it hit, in a form it can act on. + #[tokio::test] + async fn errors_serialize_as_tagged_objects() { + let err = SandboxError::ScriptTimeout { limit_secs: 120 }; + assert_eq!( + serde_json::to_value(&err).unwrap(), + json!({"error": "script_timeout", "limit_secs": 120}) + ); + } + + // -- result size -- + + /// Truncation rather than rejection: a script that already called tools + /// may have caused side effects, and an error would discard the data it + /// gathered along with them. + #[tokio::test] + async fn oversized_results_are_truncated_not_rejected() { + let sandbox = Sandbox::new(Limits { + max_result_bytes: 64, + ..fast() + }); + let outcome = sandbox + .run_script(r#"return "x".repeat(5000);"#) + .await + .expect("an oversized result is not a failure"); + + assert_eq!(outcome.json.len(), 64); + assert_eq!( + outcome.truncated_from, + Some(5002), + "the original length is reported so the model can judge the shortfall" + ); + } + + #[tokio::test] + async fn results_within_the_limit_are_untouched() { + let outcome = Sandbox::new(fast()).run_script("return 42;").await.unwrap(); + assert_eq!(outcome.json, "42"); + assert_eq!(outcome.truncated_from, None); + } + + /// The cut lands on a character boundary, so the prefix is still valid + /// text even though it is no longer valid JSON. + #[tokio::test] + async fn truncation_does_not_split_a_character() { + for limit in 8..24 { + let sandbox = Sandbox::new(Limits { + max_result_bytes: limit, + ..fast() + }); + let outcome = sandbox.run_script(r#"return "éééééééééé";"#).await.unwrap(); + assert!( + outcome.json.len() <= limit, + "limit {limit}: kept {} bytes", + outcome.json.len() + ); + // Valid UTF-8 by construction in Rust; assert the cut is where we + // expect rather than mid-sequence. + assert!( + outcome.json.is_char_boundary(outcome.json.len()), + "limit {limit}: cut mid-character" + ); + } + } + + // -- ambient capabilities -- + + /// Nothing registers these; the assertion guards against a dependency + /// bump quietly introducing one. + #[tokio::test] + async fn host_capabilities_are_absent() { + for global in [ + "fetch", + "require", + "process", + "XMLHttpRequest", + "WebSocket", + "globalThis.os", + "globalThis.std", + ] { + let out = eval(&format!("typeof {global}")).await.unwrap(); + assert_eq!(out, json!("undefined"), "{global} should not exist"); + } + } + + #[tokio::test] + async fn modules_cannot_be_imported() { + let err = eval(r#"import("fs")"#).await.unwrap_err(); + assert!( + matches!(err, SandboxError::ScriptError { .. }), + "got: {err:?}" + ); + } + + /// Each call gets a fresh runtime, so nothing a script leaves behind can + /// influence the next one. + #[tokio::test] + async fn state_does_not_leak_between_scripts() { + let sandbox = Sandbox::new(fast()); + sandbox.eval("globalThis.leaked = 1").await.unwrap(); + let out = sandbox.eval("typeof globalThis.leaked").await.unwrap(); + assert_eq!(out, json!("undefined")); + } +} diff --git a/src/mcp/schema_to_ts.rs b/src/mcp/schema_to_ts.rs new file mode 100644 index 00000000..7d4d2ec6 --- /dev/null +++ b/src/mcp/schema_to_ts.rs @@ -0,0 +1,1140 @@ +//! JSON Schema to TypeScript type expressions. +//! +//! Backing MCP servers describe their tool parameters with JSON Schema, but the +//! agent writes JavaScript against those tools. Models read TypeScript +//! declarations far more reliably than raw JSON Schema, so schemas are rendered +//! as `.d.ts`-style type expressions. +//! +//! Two rules govern everything here: +//! +//! * **Never fail.** Schemas arrive from arbitrary third-party servers across +//! several JSON Schema dialects. A construct we do not recognize renders as +//! `unknown` and generation continues. +//! * **`unknown`, not `any`.** `unknown` forces the model to narrow the value +//! rather than silently assuming a shape that may not hold. + +use serde_json::{Map, Value}; +use std::collections::BTreeMap; + +/// Rendered when a schema is missing, unrecognized, or unconstrained. +const UNKNOWN: &str = "unknown"; + +/// Deepest nesting we will follow before giving up. +/// +/// Schemas come from third parties and may nest arbitrarily; this keeps a +/// pathological one from exhausting the stack. +const MAX_DEPTH: usize = 64; + +/// Total subschemas we will render for one tool. +/// +/// Composition keywords can fan out multiplicatively, so a depth cap alone is +/// not enough to bound the work. +const MAX_NODES: usize = 10_000; + +/// Renders schemas to TypeScript, accumulating the named types they reference. +/// +/// One renderer is shared across all of a server's tools so a type defined in +/// several tools' `$defs` is emitted once. +#[derive(Debug, Default)] +pub struct TypeRenderer { + /// Type name to rendered body, ordered so output is stable. + named: BTreeMap, +} + +impl TypeRenderer { + pub fn new() -> Self { + Self::default() + } + + /// Render `schema` as a type expression, registering any named types it + /// references. `$ref` pointers resolve against `schema` itself. + pub fn render(&mut self, schema: &Value) -> String { + self.render_indented(schema, 0) + } + + /// Render at a given nesting level, so a type embedded inside another + /// block lines up with it. + pub fn render_indented(&mut self, schema: &Value, indent: usize) -> String { + let mut cx = Cx { + root: schema, + named: &mut self.named, + in_progress: Vec::new(), + budget: MAX_NODES, + }; + render_at(&mut cx, schema, indent, 0) + } + + /// The named type declarations collected so far, in name order. + /// + /// Always an alias: a body may be a union or intersection of objects (a + /// serde externally-tagged enum, say), and `interface` accepts only a + /// single object literal. + pub fn declarations(&self) -> String { + let mut out = String::new(); + for (name, body) in &self.named { + out.push_str(&format!("type {name} = {body};\n\n")); + } + out + } + + pub fn is_empty(&self) -> bool { + self.named.is_empty() + } +} + +/// Per-render state: the document `$ref` resolves against, the shared type +/// table, the names currently being rendered, and the remaining node budget. +struct Cx<'a> { + root: &'a Value, + named: &'a mut BTreeMap, + in_progress: Vec, + budget: usize, +} + +fn render_at(cx: &mut Cx, schema: &Value, indent: usize, depth: usize) -> String { + if depth > MAX_DEPTH || cx.budget == 0 { + return UNKNOWN.to_string(); + } + cx.budget -= 1; + + match schema { + // A schema may be a bare boolean: `true` accepts anything, `false` + // accepts nothing. + Value::Bool(true) => UNKNOWN.to_string(), + Value::Bool(false) => "never".to_string(), + Value::Object(map) => render_object_schema(cx, map, indent, depth), + // Anything else is malformed; degrade rather than fail. + _ => UNKNOWN.to_string(), + } +} + +fn render_object_schema( + cx: &mut Cx, + schema: &Map, + indent: usize, + depth: usize, +) -> String { + if schema.is_empty() { + return UNKNOWN.to_string(); + } + + // A `$ref` wins over every sibling keyword. Note that siblings do appear + // alongside it: one deployed generator emits `description` next to `$ref`, + // and a resolver that returned the target directly would drop it. The + // description is read from the referring schema by the caller, so it + // survives. + if let Some(Value::String(pointer)) = schema.get("$ref") { + return render_ref(cx, pointer, depth); + } + + let base = render_constrained(cx, schema, indent, depth); + + // `nullable` is an OpenAPI 3.0 extension rather than JSON Schema, but the + // schemars 0.8 line emits it for optional fields and those servers are + // deployed today. + if schema.get("nullable") == Some(&Value::Bool(true)) && base != "null" { + return join_union(vec![base, "null".to_string()]); + } + base +} + +/// Resolve a `$ref`, emitting the target as a named type and returning its +/// name. +/// +/// Naming the type is what makes recursive schemas work: a self-reference +/// resolves to a name that is already being defined, rather than expanding +/// forever. +fn render_ref(cx: &mut Cx, pointer: &str, depth: usize) -> String { + let Some(name) = type_name_for(pointer) else { + // A pointer we cannot name (an external URL, say) is unresolvable. + return UNKNOWN.to_string(); + }; + + // Already emitted, or currently being emitted higher up the stack. The + // latter is the cycle case; returning the name closes the loop. + if cx.named.contains_key(&name) || cx.in_progress.contains(&name) { + return name; + } + + let Some(target) = resolve_pointer(cx.root, pointer) else { + return UNKNOWN.to_string(); + }; + // Reserve the name before rendering the body, so a self-reference + // encountered inside sees it as in progress. + cx.in_progress.push(name.clone()); + let body = render_at(cx, &target.clone(), 0, depth + 1); + cx.in_progress.pop(); + + // `type A = A` is circular (TS2456); `{"$ref": "#"}` produces it. + let body = if body == name { + UNKNOWN.to_string() + } else { + body + }; + + cx.named.insert(name.clone(), body); + name +} + +/// Follow a JSON pointer such as `#/$defs/Message` within `root`. +fn resolve_pointer<'a>(root: &'a Value, pointer: &str) -> Option<&'a Value> { + let path = pointer.strip_prefix('#')?; + if path.is_empty() || path == "/" { + return Some(root); + } + let mut current = root; + for raw in path.trim_start_matches('/').split('/') { + // Per RFC 6901, `~1` is an escaped `/` and `~0` an escaped `~`. + let key = raw.replace("~1", "/").replace("~0", "~"); + current = current.get(&key)?; + } + Some(current) +} + +/// Derive a TypeScript type name from a pointer's last segment. +fn type_name_for(pointer: &str) -> Option { + if !pointer.starts_with('#') { + return None; + } + let last = pointer.rsplit('/').next()?; + let cleaned: String = last + .replace("~1", "/") + .replace("~0", "~") + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + if cleaned.is_empty() { + return None; + } + // A type name may not start with a digit, nor be a reserved word. + if cleaned.starts_with(|c: char| c.is_ascii_digit()) || is_reserved_type_name(&cleaned) { + Some(format!("_{cleaned}")) + } else { + Some(cleaned) + } +} + +/// Names TypeScript will not accept as a declared type. `$defs` keys are the +/// server's own field names, so `default` and `string` are both plausible. +fn is_reserved_type_name(name: &str) -> bool { + const RESERVED: &[&str] = &[ + // Built-in and intrinsic types. + "any", + "bigint", + "boolean", + "never", + "null", + "number", + "object", + "string", + "symbol", + "undefined", + "unknown", + "void", + // Keywords that cannot begin a declaration name. + "break", + "case", + "catch", + "class", + "const", + "continue", + "debugger", + "default", + "delete", + "do", + "else", + "enum", + "export", + "extends", + "false", + "finally", + "for", + "function", + "if", + "import", + "in", + "instanceof", + "new", + "return", + "super", + "switch", + "this", + "throw", + "true", + "try", + "typeof", + "var", + "void", + "while", + "with", + ]; + RESERVED.contains(&name) +} + +/// Dispatch on whichever constraint keyword the schema uses, narrowest first. +fn render_constrained( + cx: &mut Cx, + schema: &Map, + indent: usize, + depth: usize, +) -> String { + // A single permitted value is narrower than anything else present. + if let Some(value) = schema.get("const") { + return render_literal(value); + } + + // `enum` constrains the value regardless of any declared `type`. + if let Some(Value::Array(values)) = schema.get("enum") { + return render_enum(values); + } + + // Composition keywords. `allOf` combines constraints; `anyOf`/`oneOf` + // offer alternatives. A single-element `allOf` — the shape generators emit + // to attach a description to a reference — collapses to its one member. + if let Some(Value::Array(members)) = schema.get("allOf") { + return render_intersection(cx, members, indent, depth); + } + for key in ["anyOf", "oneOf"] { + if let Some(Value::Array(members)) = schema.get(key) { + return render_union(cx, members, indent, depth); + } + } + + match schema.get("type") { + Some(Value::String(ty)) => render_with_type(cx, schema, ty, indent, depth), + // A type may be a list of alternatives, e.g. `["string", "null"]`. + Some(Value::Array(types)) => { + let parts = types + .iter() + .filter_map(Value::as_str) + .map(|ty| render_with_type(cx, schema, ty, indent, depth)) + .collect(); + join_union(parts) + } + _ => UNKNOWN.to_string(), + } +} + +fn render_with_type( + cx: &mut Cx, + schema: &Map, + ty: &str, + indent: usize, + depth: usize, +) -> String { + match ty { + "string" => "string".to_string(), + // JSON Schema separates integers from other numbers; TypeScript does not. + "number" | "integer" => "number".to_string(), + "boolean" => "boolean".to_string(), + "null" => "null".to_string(), + "array" => render_array(cx, schema, indent, depth), + "object" => render_struct(cx, schema, indent, depth), + _ => UNKNOWN.to_string(), + } +} + +fn render_array(cx: &mut Cx, schema: &Map, indent: usize, depth: usize) -> String { + let Some(items) = schema.get("items") else { + return format!("{UNKNOWN}[]"); + }; + // `A | B[]` would parse as `A | (B[])`, so a union element needs parens. + let inner = parenthesize_if_composite(render_at(cx, items, indent, depth + 1)); + format!("{inner}[]") +} + +fn render_struct(cx: &mut Cx, schema: &Map, indent: usize, depth: usize) -> String { + let Some(Value::Object(properties)) = schema.get("properties") else { + // No declared properties: the schema describes a map, and + // `additionalProperties` types its values. + // + // `propertyNames` is deliberately ignored — it constrains keys, which + // TypeScript index signatures cannot express. + return match schema.get("additionalProperties") { + Some(Value::Bool(false)) => "{}".to_string(), + Some(value @ Value::Object(_)) => { + format!( + "Record", + render_at(cx, value, indent, depth + 1) + ) + } + _ => format!("Record"), + }; + }; + + if properties.is_empty() { + return "{}".to_string(); + } + + let required: Vec<&str> = match schema.get("required") { + Some(Value::Array(names)) => names.iter().filter_map(Value::as_str).collect(), + _ => Vec::new(), + }; + + let pad = " ".repeat(indent + 1); + let close_pad = " ".repeat(indent); + let mut out = String::from("{\n"); + + for (name, subschema) in properties { + if let Some(doc) = doc_comment(subschema) { + out.push_str(&format!("{pad}/** {doc} */\n")); + } + let optional = if required.contains(&name.as_str()) { + "" + } else { + "?" + }; + let rendered = render_at(cx, subschema, indent + 1, depth + 1); + out.push_str(&format!( + "{pad}{}{optional}: {rendered};\n", + property_key(name) + )); + } + + out.push_str(&close_pad); + out.push('}'); + out +} + +fn render_union(cx: &mut Cx, members: &[Value], indent: usize, depth: usize) -> String { + let mut parts = Vec::new(); + for member in members { + let rendered = render_at(cx, member, indent, depth + 1); + if !is_direct_self_reference(cx, &rendered) { + parts.push(rendered); + } + } + join_union(parts) +} + +fn render_intersection(cx: &mut Cx, members: &[Value], indent: usize, depth: usize) -> String { + let mut parts: Vec = Vec::new(); + for member in members { + let rendered = render_at(cx, member, indent, depth + 1); + if is_direct_self_reference(cx, &rendered) { + continue; + } + let rendered = parenthesize_if_composite(rendered); + if !parts.contains(&rendered) { + parts.push(rendered); + } + } + match parts.len() { + 0 => UNKNOWN.to_string(), + 1 => parts.pop().unwrap_or_default(), + _ => parts.join(" & "), + } +} + +/// Whether a rendered member is the type currently being defined. +/// +/// `type A = A | string` is circular (TS2456), and a member that *is* the +/// whole type constrains nothing. Only a bare name counts: `type Json = +/// string | Json[]` is legitimate recursion. +fn is_direct_self_reference(cx: &Cx, rendered: &str) -> bool { + cx.in_progress.iter().any(|name| name == rendered) +} + +fn render_enum(values: &[Value]) -> String { + if values.is_empty() { + return "never".to_string(); + } + join_union(values.iter().map(render_literal).collect()) +} + +/// Join alternatives into a union, dropping duplicates and flattening the +/// `unknown` case — `T | unknown` is just `unknown`. +fn join_union(parts: Vec) -> String { + let mut unique: Vec = Vec::new(); + for part in parts { + if part == UNKNOWN { + return UNKNOWN.to_string(); + } + if !unique.contains(&part) { + unique.push(part); + } + } + match unique.len() { + 0 => UNKNOWN.to_string(), + 1 => unique.pop().unwrap_or_default(), + _ => unique.join(" | "), + } +} + +/// Parenthesize a composite so it binds correctly inside a larger type. +/// +/// `A | B[]` parses as `A | (B[])`, and `A & B[]` as `A & (B[])`. +fn parenthesize_if_composite(rendered: String) -> String { + let composite = rendered.contains(" | ") || rendered.contains(" & "); + if composite && !rendered.starts_with('(') { + format!("({rendered})") + } else { + rendered + } +} + +/// Render a JSON value as a TypeScript literal type. +fn render_literal(value: &Value) -> String { + match value { + Value::String(s) => format!("{s:?}"), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => "null".to_string(), + // An object or array literal has no TypeScript literal-type spelling. + _ => UNKNOWN.to_string(), + } +} + +/// A property's `description`, collapsed to one line and safe inside a JSDoc +/// block. +fn doc_comment(schema: &Value) -> Option { + jsdoc_text(schema.get("description")?.as_str()?) +} + +/// Collapse text to one line and make it safe inside a JSDoc block. +pub(crate) fn jsdoc_text(text: &str) -> Option { + let text = text.trim(); + if text.is_empty() { + return None; + } + // `*/` inside a JSDoc comment would end it early. + let collapsed = text.split_whitespace().collect::>().join(" "); + Some(collapsed.replace("*/", "* /")) +} + +/// Quote a property name unless it is a valid JavaScript identifier. +/// +/// Tool schemas routinely use names that are not identifiers (`content-type`, +/// `2fa`), and those are legal as quoted property keys. +fn property_key(name: &str) -> String { + if is_js_identifier(name) { + name.to_string() + } else { + format!("{name:?}") + } +} + +fn is_js_identifier(name: &str) -> bool { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !(first.is_ascii_alphabetic() || first == '_' || first == '$') { + return false; + } + chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn render(schema: Value) -> String { + TypeRenderer::new().render(&schema) + } + + // -- scalars -- + + #[test] + fn renders_scalar_types() { + assert_eq!(render(json!({"type": "string"})), "string"); + assert_eq!(render(json!({"type": "boolean"})), "boolean"); + assert_eq!(render(json!({"type": "null"})), "null"); + } + + /// JSON Schema distinguishes integers from other numbers; TypeScript has + /// only `number`. + #[test] + fn renders_integer_as_number() { + assert_eq!(render(json!({"type": "number"})), "number"); + assert_eq!(render(json!({"type": "integer"})), "number"); + } + + // -- degradation -- + + #[test] + fn renders_unconstrained_schemas_as_unknown() { + assert_eq!(render(json!({})), "unknown"); + assert_eq!(render(json!(true)), "unknown"); + assert_eq!(render(json!({"type": "not-a-type"})), "unknown"); + assert_eq!(render(json!({"minimum": 3})), "unknown"); + } + + #[test] + fn renders_uninhabited_schemas_as_never() { + assert_eq!(render(json!(false)), "never"); + assert_eq!(render(json!({"enum": []})), "never"); + } + + // -- arrays -- + + #[test] + fn renders_arrays() { + assert_eq!( + render(json!({"type": "array", "items": {"type": "string"}})), + "string[]" + ); + assert_eq!(render(json!({"type": "array"})), "unknown[]"); + } + + /// `A | B[]` parses as `A | (B[])`, so a union element must be parenthesized. + #[test] + fn parenthesizes_union_array_elements() { + let out = render(json!({ + "type": "array", + "items": {"enum": ["a", "b"]}, + })); + assert_eq!(out, r#"("a" | "b")[]"#); + } + + /// The same hazard one keyword over: `A & B[]` parses as `A & (B[])`. + /// Unlike the union case this stayed valid syntax, so nothing downstream + /// reported it — the type was simply wrong. + #[test] + fn parenthesizes_intersection_array_elements() { + let out = render(json!({ + "type": "array", + "items": { + "allOf": [ + {"type": "object", "properties": {"a": {"type": "string"}}}, + {"type": "object", "properties": {"b": {"type": "number"}}}, + ], + }, + })); + assert!(out.starts_with('('), "should be parenthesized, got: {out}"); + assert!(out.ends_with(")[]"), "got: {out}"); + } + + // -- enums -- + + #[test] + fn renders_string_enum_as_literal_union() { + assert_eq!(render(json!({"enum": ["a", "b"]})), r#""a" | "b""#); + } + + /// Enum members are not always strings, and a declared `type` must not + /// override the narrower `enum` constraint. + #[test] + fn renders_mixed_enum_and_ignores_declared_type() { + assert_eq!(render(json!({"enum": [1, true, null]})), "1 | true | null"); + assert_eq!( + render(json!({"type": "boolean", "enum": [true]})), + "true", + "enum should win over type" + ); + } + + // -- references and named types -- + + /// Render a schema and return both the expression and the declarations of + /// every named type it pulled in. + fn render_with_defs(schema: Value) -> (String, String) { + let mut r = TypeRenderer::new(); + let expr = r.render(&schema); + (expr, r.declarations()) + } + + #[test] + fn resolves_ref_into_a_named_type() { + let (expr, defs) = render_with_defs(json!({ + "type": "object", + "properties": {"msg": {"$ref": "#/$defs/Message"}}, + "required": ["msg"], + "$defs": { + "Message": { + "type": "object", + "properties": {"text": {"type": "string"}}, + "required": ["text"], + }, + }, + })); + assert_eq!(expr, "{\n msg: Message;\n}"); + assert_eq!(defs, "type Message = {\n text: string;\n};\n\n"); + } + + /// A serde externally-tagged enum — the most common schemars-generated + /// `$def`. Its body is a union of objects, so it starts with `{` without + /// being an object literal; declaring it as an `interface` produced + /// `interface Shape { … } | { … }`, a syntax error that took the whole + /// declaration file down with it. + #[test] + fn a_union_bodied_named_type_is_declared_as_an_alias() { + let (_, defs) = render_with_defs(json!({ + "type": "object", + "properties": {"shape": {"$ref": "#/$defs/Shape"}}, + "$defs": { + "Shape": { + "oneOf": [ + {"type": "object", "properties": {"Circle": {"type": "number"}}, + "required": ["Circle"]}, + {"type": "object", "properties": {"Square": {"type": "number"}}, + "required": ["Square"]}, + ], + }, + }, + })); + assert!(defs.starts_with("type Shape = {"), "got:\n{defs}"); + assert!(defs.contains("} | {"), "got:\n{defs}"); + assert!(!defs.contains("interface"), "got:\n{defs}"); + } + + /// Same shape, one keyword over: an intersection body. + #[test] + fn an_intersection_bodied_named_type_is_declared_as_an_alias() { + let (_, defs) = render_with_defs(json!({ + "type": "object", + "properties": {"merged": {"$ref": "#/$defs/Merged"}}, + "$defs": { + "Merged": { + "allOf": [ + {"type": "object", "properties": {"a": {"type": "string"}}}, + {"type": "object", "properties": {"b": {"type": "number"}}}, + ], + }, + }, + })); + assert!(defs.starts_with("type Merged = {"), "got:\n{defs}"); + assert!(defs.contains("} & {"), "got:\n{defs}"); + } + + /// `type A = A | string` is a circular alias TypeScript rejects (TS2456). + /// A member that is the whole type constrains nothing, so it drops out. + #[test] + fn a_direct_self_reference_does_not_produce_a_circular_alias() { + let (_, defs) = render_with_defs(json!({ + "type": "object", + "properties": {"a": {"$ref": "#/$defs/A"}}, + "$defs": { + "A": {"anyOf": [{"$ref": "#/$defs/A"}, {"type": "string"}]}, + }, + })); + assert_eq!(defs, "type A = string;\n\n", "got:\n{defs}"); + } + + /// Recursion through a constructor is legitimate and must survive: the + /// reference sits inside an array rather than being the alternative. + #[test] + fn recursion_through_an_array_is_kept() { + let (_, defs) = render_with_defs(json!({ + "type": "object", + "properties": {"tree": {"$ref": "#/$defs/Json"}}, + "$defs": { + "Json": { + "anyOf": [ + {"type": "string"}, + {"type": "array", "items": {"$ref": "#/$defs/Json"}}, + ], + }, + }, + })); + assert_eq!(defs, "type Json = string | Json[];\n\n", "got:\n{defs}"); + } + + /// A pointer to the whole document refers to itself and can only be + /// `unknown`. + #[test] + fn a_root_self_reference_degrades() { + let (_, defs) = render_with_defs(json!({"$ref": "#"})); + assert!(!defs.contains("_ = _"), "circular alias, got:\n{defs}"); + } + + /// `$defs` keys are the server's own field names, so a reserved word is + /// plausible. `type default =` is a parse error and `type string =` is a + /// redeclaration. + #[test] + fn reserved_words_are_not_used_as_type_names() { + for word in ["default", "string", "function", "enum"] { + let (_, defs) = render_with_defs(json!({ + "type": "object", + "properties": {"v": {"$ref": format!("#/$defs/{word}")}}, + "$defs": {word: {"type": "string"}}, + })); + assert!( + defs.starts_with(&format!("type _{word} =")), + "`{word}` should be prefixed, got:\n{defs}" + ); + } + } + + /// The older dialect spells the same thing `definitions`. + #[test] + fn resolves_legacy_definitions_pointer() { + let (expr, defs) = render_with_defs(json!({ + "$ref": "#/definitions/Role", + "definitions": {"Role": {"enum": ["admin", "user"]}}, + })); + assert_eq!(expr, "Role"); + assert_eq!(defs, "type Role = \"admin\" | \"user\";\n\n"); + } + + /// Naming the target is what makes recursion terminate: the self-reference + /// resolves to a name that is already being defined. + #[test] + fn recursive_ref_terminates() { + let (expr, defs) = render_with_defs(json!({ + "$ref": "#/$defs/Node", + "$defs": { + "Node": { + "type": "object", + "properties": { + "children": {"type": "array", "items": {"$ref": "#/$defs/Node"}}, + }, + }, + }, + })); + assert_eq!(expr, "Node"); + assert!( + defs.contains("children?: Node[];"), + "self-reference should render as the name, got:\n{defs}" + ); + } + + /// Two schemas referring to each other must not expand forever. + #[test] + fn mutually_recursive_refs_terminate() { + let (_, defs) = render_with_defs(json!({ + "$ref": "#/$defs/A", + "$defs": { + "A": {"type": "object", "properties": {"b": {"$ref": "#/$defs/B"}}}, + "B": {"type": "object", "properties": {"a": {"$ref": "#/$defs/A"}}}, + }, + })); + assert!(defs.contains("type A ="), "got:\n{defs}"); + assert!(defs.contains("type B ="), "got:\n{defs}"); + } + + /// One deployed generator emits `description` beside `$ref`. Resolving the + /// reference must not discard it. + #[test] + fn ref_with_sibling_description_keeps_the_description() { + let (expr, _) = render_with_defs(json!({ + "type": "object", + "properties": { + "msg": { + "$ref": "#/$defs/Message", + "description": "the message", + }, + }, + "$defs": {"Message": {"type": "string"}}, + })); + assert!(expr.contains("/** the message */"), "got:\n{expr}"); + assert!(expr.contains("msg?: Message;"), "got:\n{expr}"); + } + + /// A type shared by two properties is emitted once. + #[test] + fn shared_ref_is_emitted_once() { + let (_, defs) = render_with_defs(json!({ + "type": "object", + "properties": { + "from": {"$ref": "#/$defs/User"}, + "to": {"$ref": "#/$defs/User"}, + }, + "$defs": {"User": {"type": "object", "properties": {"id": {"type": "string"}}}}, + })); + assert_eq!(defs.matches("type User =").count(), 1, "got:\n{defs}"); + } + + #[test] + fn unresolvable_refs_degrade_to_unknown() { + assert_eq!(render(json!({"$ref": "#/$defs/Missing"})), "unknown"); + assert_eq!( + render(json!({"$ref": "https://example.com/schema.json"})), + "unknown", + "an external reference cannot be resolved offline" + ); + } + + /// A renderer is shared across a server's tools, so a type defined in two + /// tools' `$defs` is emitted once. + #[test] + fn named_types_accumulate_across_renders() { + let mut r = TypeRenderer::new(); + for _ in 0..2 { + r.render(&json!({ + "$ref": "#/$defs/Shared", + "$defs": {"Shared": {"type": "string"}}, + })); + } + assert_eq!(r.declarations().matches("type Shared").count(), 1); + } + + // -- traversal bounds -- + + /// Schemas arrive from third parties; deep nesting must not exhaust the + /// stack. + #[test] + fn deep_nesting_is_bounded() { + let mut schema = json!({"type": "string"}); + for _ in 0..(MAX_DEPTH + 50) { + schema = json!({"type": "array", "items": schema}); + } + let out = render(schema); + assert!(out.ends_with("[]"), "should still render, got: {out}"); + assert!( + out.contains(UNKNOWN), + "the bound should show up as unknown at the bottom, got: {out}" + ); + } + + // -- unions and composition -- + + /// The dominant Python generator emits this for every optional field, and + /// it is the single most common construct the naive mapping would drop. + #[test] + fn renders_nullable_anyof_as_union() { + let out = render(json!({ + "anyOf": [{"type": "string"}, {"type": "null"}], + "default": null, + })); + assert_eq!(out, "string | null"); + } + + /// Optionality is decided by `required`. A nullable field that *is* + /// required stays non-optional; only its type gains `null`. + #[test] + fn nullable_union_does_not_imply_optional() { + let out = render(json!({ + "type": "object", + "properties": { + "a": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + }, + "required": ["a"], + })); + assert_eq!(out, "{\n a: string | null;\n}"); + } + + #[test] + fn renders_oneof_as_union() { + let out = render(json!({ + "oneOf": [{"type": "string"}, {"type": "number"}], + })); + assert_eq!(out, "string | number"); + } + + #[test] + fn renders_allof_as_intersection() { + let out = render(json!({ + "allOf": [ + {"type": "object", "properties": {"a": {"type": "string"}}}, + {"type": "object", "properties": {"b": {"type": "number"}}}, + ], + })); + assert_eq!(out, "{\n a?: string;\n} & {\n b?: number;\n}"); + } + + /// Generators wrap a single member in `allOf` to attach a sibling + /// description. That must collapse rather than render a one-sided `&`. + #[test] + fn collapses_single_member_allof() { + let out = render(json!({ + "allOf": [{"type": "string"}], + "description": "wrapped", + })); + assert_eq!(out, "string"); + } + + /// `T | unknown` is just `unknown`, and repeated alternatives collapse. + #[test] + fn union_absorbs_unknown_and_drops_duplicates() { + assert_eq!( + render(json!({"anyOf": [{"type": "string"}, {}]})), + "unknown" + ); + assert_eq!( + render(json!({"anyOf": [{"type": "string"}, {"type": "string"}]})), + "string" + ); + } + + // -- type as a list -- + + #[test] + fn renders_array_valued_type_as_union() { + assert_eq!(render(json!({"type": ["string", "null"]})), "string | null"); + assert_eq!( + render(json!({"type": ["string", "number", "boolean"]})), + "string | number | boolean" + ); + } + + // -- const -- + + #[test] + fn renders_const_as_literal() { + assert_eq!(render(json!({"const": "resource"})), r#""resource""#); + assert_eq!(render(json!({"const": 3})), "3"); + } + + // -- nullable (OpenAPI spelling) -- + + /// One deployed Rust generator marks optional fields with `nullable` + /// rather than a union or a null-typed alternative. + #[test] + fn renders_openapi_nullable_as_union() { + let out = render(json!({ + "type": "array", + "items": {"type": "string"}, + "nullable": true, + })); + assert_eq!(out, "string[] | null"); + } + + #[test] + fn nullable_null_type_does_not_repeat_null() { + assert_eq!(render(json!({"type": "null", "nullable": true})), "null"); + } + + // -- records -- + + #[test] + fn renders_typed_additional_properties_as_record() { + let out = render(json!({ + "type": "object", + "additionalProperties": {"type": "string"}, + })); + assert_eq!(out, "Record"); + } + + /// `propertyNames` constrains keys, which a TypeScript index signature + /// cannot express, so it is ignored rather than degrading the type. + #[test] + fn ignores_property_names_constraint() { + let out = render(json!({ + "type": "object", + "propertyNames": {"type": "string"}, + "additionalProperties": {"type": "string"}, + })); + assert_eq!(out, "Record"); + } + + #[test] + fn renders_closed_empty_object_as_empty_type() { + let out = render(json!({"type": "object", "additionalProperties": false})); + assert_eq!(out, "{}"); + } + + // -- objects -- + + #[test] + fn renders_object_with_required_and_optional_properties() { + let out = render(json!({ + "type": "object", + "properties": { + "sql": {"type": "string"}, + "limit": {"type": "integer"}, + }, + "required": ["sql"], + })); + assert_eq!(out, "{\n limit?: number;\n sql: string;\n}"); + } + + /// Optionality comes from `required` alone. Nothing else marks a property + /// optional. + #[test] + fn properties_are_optional_when_required_is_absent() { + let out = render(json!({ + "type": "object", + "properties": {"a": {"type": "string"}}, + })); + assert_eq!(out, "{\n a?: string;\n}"); + } + + /// `additionalProperties: false` is the single most common construct in + /// real tool schemas. Degrading on it would turn every ordinary object into + /// `unknown`. + #[test] + fn additional_properties_false_does_not_degrade_the_object() { + let out = render(json!({ + "type": "object", + "properties": {"a": {"type": "string"}}, + "required": ["a"], + "additionalProperties": false, + })); + assert_eq!(out, "{\n a: string;\n}"); + } + + #[test] + fn renders_object_without_properties_as_open_map() { + assert_eq!(render(json!({"type": "object"})), "Record"); + assert_eq!(render(json!({"type": "object", "properties": {}})), "{}"); + } + + #[test] + fn renders_nested_objects_with_indentation() { + let out = render(json!({ + "type": "object", + "properties": { + "outer": { + "type": "object", + "properties": {"inner": {"type": "string"}}, + "required": ["inner"], + }, + }, + "required": ["outer"], + })); + assert_eq!( + out, "{\n outer: {\n inner: string;\n };\n}", + "nested braces should indent, got:\n{out}" + ); + } + + // -- property names -- + + /// Tool schemas use property names that are not JavaScript identifiers. + #[test] + fn quotes_property_names_that_are_not_identifiers() { + let out = render(json!({ + "type": "object", + "properties": { + "content-type": {"type": "string"}, + "2fa": {"type": "boolean"}, + "ok_name": {"type": "string"}, + }, + })); + assert!(out.contains(r#""content-type"?: string;"#), "got:\n{out}"); + assert!(out.contains(r#""2fa"?: boolean;"#), "got:\n{out}"); + assert!( + out.contains("ok_name?: string;"), + "valid identifiers stay unquoted, got:\n{out}" + ); + } + + // -- documentation -- + + #[test] + fn renders_description_as_jsdoc() { + let out = render(json!({ + "type": "object", + "properties": { + "sql": {"type": "string", "description": "The query to run"}, + }, + })); + assert_eq!(out, "{\n /** The query to run */\n sql?: string;\n}"); + } + + /// A description containing `*/` would close the JSDoc block early. + #[test] + fn escapes_comment_terminator_in_description() { + let out = render(json!({ + "type": "object", + "properties": { + "a": {"type": "string", "description": "ends the block */ here"}, + }, + })); + assert!(!out.contains("*/ here"), "got:\n{out}"); + assert!(out.contains("* / here"), "got:\n{out}"); + } + + #[test] + fn collapses_multiline_descriptions() { + let out = render(json!({ + "type": "object", + "properties": { + "a": {"type": "string", "description": "first line\n second line"}, + }, + })); + assert!(out.contains("/** first line second line */"), "got:\n{out}"); + } +} diff --git a/src/mcp/server.rs b/src/mcp/server.rs new file mode 100644 index 00000000..8f1f2edd --- /dev/null +++ b/src/mcp/server.rs @@ -0,0 +1,399 @@ +//! The meta-server itself. +//! +//! One MCP server is registered with the agent, exposing two tools rather +//! than every plugin server's tools directly: `list_tools` describes what is +//! available, `execute` runs a script against it. +//! +//! Two constraints shape the process: +//! +//! * **stdout carries JSON-RPC.** Anything else written there corrupts the +//! stream, so all reporting goes to stderr. +//! * **Startup has no side effects.** Some clients probe a server by +//! spawning a throwaway copy before the real session, so anything done at +//! startup happens twice, from a process nobody will talk to. + +use std::sync::Arc; + +use rmcp::handler::server::ServerHandler; +use rmcp::model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, Implementation, + ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool, ToolAnnotations, +}; +use rmcp::service::RequestContext; +use rmcp::{ErrorData as McpError, RoleServer, ServiceExt}; +use serde_json::{Map, Value, json}; + +/// Tool that describes what is reachable. +pub const LIST_TOOLS: &str = "list_tools"; +/// Tool that runs a script. +pub const EXECUTE: &str = "execute"; + +/// Serves the two meta-tools over stdio. +#[derive(Clone)] +pub struct MetaServer { + catalog: Arc, + limits: super::sandbox::Limits, +} + +impl MetaServer { + pub fn new(catalog: Arc, limits: super::sandbox::Limits) -> Self { + Self { catalog, limits } + } + + /// Run a model-written script with the workspace's tools in scope. + async fn execute(&self, script: &str) -> CallToolResult { + let (namespaces, problems) = self.catalog.namespaces().await; + if namespaces.is_empty() { + let mut message = + String::from("No MCP tools are in scope, so there is nothing to call."); + for problem in &problems { + message.push_str(&format!("\n {problem}")); + } + return CallToolResult::error(vec![ContentBlock::text(message)]); + } + + // Tool calls cross back to this runtime: backing servers are child + // processes owned here, and their I/O cannot be polled from the + // engine's thread. + let (calls, mut receiver) = super::dispatch::channel(); + let catalog = Arc::clone(&self.catalog); + // A server whose answer did not match its declared output schema is + // reported alongside the result. Collected here because the notice is + // for the model, not for the script, which receives the value either + // way. + let notices: Arc>> = + Arc::new(std::sync::Mutex::new(Vec::new())); + let collected = Arc::clone(¬ices); + let pump = tokio::spawn(async move { + while let Some(call) = receiver.recv().await { + let answer = match catalog.call(&call.server, &call.tool, call.args).await { + Ok(outcome) => { + if let Some(notice) = outcome.notice { + let mut seen = collected.lock().expect("notice lock"); + // The same tool answering off-shape ten times is + // one fact about that tool, not ten. + if !seen.contains(¬ice) { + seen.push(notice); + } + } + Ok(outcome.value) + } + Err(e) => Err(e), + }; + let _ = call.reply.send(answer); + } + }); + + let outcome = super::sandbox::Sandbox::new(self.limits) + .run_script_with(script, &namespaces, calls) + .await; + // Aborted, not awaited: the sandbox owns the only senders, so waiting + // for the channel to close means waiting on the engine thread. Once + // the script is over, anything still queued is past its deadline. + pump.abort(); + + // Schema mismatches join the pre-existing problems, so everything the + // model needs to interpret the result arrives with it. + let mut problems = problems; + problems.extend(notices.lock().expect("notice lock").drain(..)); + + match outcome { + Ok(outcome) => CallToolResult::success(vec![ContentBlock::text(render_outcome( + &outcome, &problems, + ))]), + Err(e) => { + // Tagged rather than prose, so the model can tell a limit it + // exceeded from a mistake in its own code. + let detail = + serde_json::to_string(&e).unwrap_or_else(|_| format!("{{\"error\":\"{e}\"}}")); + CallToolResult::error(vec![ContentBlock::text(detail)]) + } + } + } + + /// The inventory carried in `execute`'s description. + /// + /// Naming the servers costs a few tokens each and saves a round trip: a + /// small workspace needs no discovery call at all. Their tools are not + /// listed here, so the cost grows with servers rather than with schemas. + fn execute_description(&self) -> String { + // Read rather than cached: the set can change mid-session. + let servers = self.catalog.server_names(); + let mut text = String::from( + "Run a JavaScript program with the workspace's MCP tools in scope.\n\n\ + Each server is an object whose methods return promises, so a program \ + can call several tools, filter between them, and return only what \ + matters:\n\n \ + const rows = await sqlx.query({ sql: \"SELECT id FROM users\" });\n \ + return rows.filter(r => r.id > 100);\n\n\ + Write an async arrow function or a statement body that returns a value. \ + Write plain JavaScript: no type annotations, interfaces, or generics.\n\n", + ); + if servers.is_empty() { + text.push_str( + "No MCP servers apply to this workspace. Nothing is in scope for `execute`.", + ); + } else { + text.push_str(&format!( + "Servers in scope: {}.\nCall `{LIST_TOOLS}` for their tools and signatures.", + servers.join(", ") + )); + } + text + } + + fn tool_definitions(&self) -> Vec { + let list = Tool::new( + LIST_TOOLS, + "Describe the MCP tools available in this workspace. Returns names and \ + descriptions by default; pass a filter or a higher detail level for full \ + TypeScript signatures.", + object_schema(json!({ + "type": "object", + "properties": { + "servers": { + "type": "array", "items": {"type": "string"}, + "description": "Restrict to these servers." + }, + "tools": { + "type": "array", "items": {"type": "string"}, + "description": "Restrict to these tool names." + }, + "pattern": { + "type": "string", + "description": "Glob matched against tool names." + }, + "detail": { + "type": "string", "enum": ["names", "signatures", "full"], + "description": "How much to return. Defaults to names." + } + } + })), + ) + .annotate(ToolAnnotations::new().read_only(true)); + + let execute = Tool::new( + EXECUTE, + self.execute_description(), + object_schema(json!({ + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The JavaScript program to run." + } + }, + "required": ["script"] + })), + ) + // A script can call any tool in scope, so the annotation has to + // describe the worst of them. Marking it read-only would let a + // filtering client treat arbitrary tool use as safe. + .annotate( + ToolAnnotations::new() + .read_only(false) + .destructive(true) + .open_world(true), + ); + + vec![list, execute] + } +} + +impl ServerHandler for MetaServer { + fn get_info(&self) -> ServerInfo { + // The negotiated version is left to the SDK: answering with a version + // the client did not offer is a hard error that closes the transport. + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(Implementation::new("symposium", env!("CARGO_PKG_VERSION"))) + .with_instructions( + "Symposium exposes this workspace's MCP tools through two tools. \ + Start with `execute`; its description names the servers in scope.", + ) + } + + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(ListToolsResult::with_all_items(self.tool_definitions())) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + _context: RequestContext, + ) -> Result { + match request.name.as_ref() { + LIST_TOOLS => { + let args = request.arguments.map(Value::Object).unwrap_or(Value::Null); + let query = super::catalog::Query::from_arguments(&args); + let body = self.catalog.describe(&query).await; + Ok(CallToolResult::success(vec![ContentBlock::text(body)]).into()) + } + EXECUTE => { + let args = request.arguments.map(Value::Object).unwrap_or(Value::Null); + let Some(script) = args.get("script").and_then(Value::as_str) else { + return Err(McpError::invalid_params( + "`execute` requires a `script` string", + None, + )); + }; + Ok(self.execute(script).await.into()) + } + other => Err(McpError::invalid_params( + format!("no such tool: {other}. Available: {LIST_TOOLS}, {EXECUTE}"), + None, + )), + } + } +} + +/// Present a script's result to the model. +/// +/// Console output and truncation are reported alongside the value rather than +/// folded into it, so a script that logged its way to an answer is readable +/// without the log being mistaken for the answer. +fn render_outcome(outcome: &super::sandbox::Outcome, problems: &[String]) -> String { + let mut text = outcome.json.clone(); + + if let Some(original) = outcome.truncated_from { + text.push_str(&format!( + "\n\n[result truncated from {original} bytes. Return less data \ + — filter or select fields inside the script.]" + )); + } + if !outcome.logs.is_empty() { + text.push_str("\n\nconsole:\n"); + for line in &outcome.logs { + text.push_str(&format!(" {line}\n")); + } + if outcome.logs_dropped { + text.push_str(" [further output dropped]\n"); + } + } + for problem in problems { + text.push_str(&format!("\n[{problem}]")); + } + text +} + +fn object_schema(value: Value) -> Map { + match value { + Value::Object(map) => map, + _ => Map::new(), + } +} + +/// Serve until the client disconnects. +pub async fn serve( + catalog: Arc, + limits: super::sandbox::Limits, +) -> anyhow::Result<()> { + let service = MetaServer::new(Arc::clone(&catalog), limits) + .serve(rmcp::transport::io::stdio()) + .await?; + let outcome = service.waiting().await; + // Backing servers outlive the connection otherwise; their process groups + // die with this process, but a clean close lets them exit on their own. + catalog.shutdown().await; + outcome?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mcp::catalog::Catalog; + use crate::mcp::supervisor::RestartPolicy; + + /// A server with no backing processes; enough to inspect what it + /// advertises. + fn test_server(names: &[&str]) -> MetaServer { + let tmp = tempfile::tempdir().expect("temp dir"); + let sym = Arc::new(crate::config::Symposium::from_dir(tmp.path())); + let resolution = crate::mcp::resolve::Resolution { + servers: names + .iter() + .map(|name| crate::mcp::resolve::ResolvedServer { + name: name.to_string(), + command: crate::mcp::resolve::ServerCommand::Path("/usr/bin/true".into()), + args: Vec::new(), + env: Vec::new(), + cwd: None, + startup_timeout: std::time::Duration::from_secs(30), + tool_call_timeout: std::time::Duration::from_secs(60), + enabled_tools: None, + disabled_tools: None, + requirements: Vec::new(), + }) + .collect(), + rejected: Vec::new(), + }; + let catalog = Catalog::new( + sym, + resolution, + RestartPolicy::default(), + false, + tmp.path().to_path_buf(), + ); + MetaServer::new(Arc::new(catalog), crate::mcp::sandbox::Limits::default()) + } + + #[test] + fn advertises_exactly_two_tools() { + let names: Vec = test_server(&[]) + .tool_definitions() + .iter() + .map(|t| t.name.to_string()) + .collect(); + assert_eq!(names, vec![LIST_TOOLS.to_string(), EXECUTE.to_string()]); + } + + /// A client filtering on read-only annotations must not conclude that + /// running arbitrary code is safe. + #[test] + fn execute_is_annotated_as_destructive() { + let tools = test_server(&[]).tool_definitions(); + let execute = tools.iter().find(|t| t.name == EXECUTE).unwrap(); + let annotations = execute.annotations.as_ref().expect("annotations"); + assert_eq!(annotations.read_only_hint, Some(false)); + assert_eq!(annotations.destructive_hint, Some(true)); + assert_eq!(annotations.open_world_hint, Some(true)); + } + + #[test] + fn list_tools_is_annotated_read_only() { + let tools = test_server(&[]).tool_definitions(); + let list = tools.iter().find(|t| t.name == LIST_TOOLS).unwrap(); + assert_eq!( + list.annotations.as_ref().and_then(|a| a.read_only_hint), + Some(true) + ); + } + + /// The inventory rides in the description so a small workspace needs no + /// discovery round trip. + #[test] + fn execute_description_names_servers_in_scope() { + let server = test_server(&["sqlx", "sea_orm"]); + let text = server.execute_description(); + assert!(text.contains("Servers in scope: sqlx, sea_orm."), "{text}"); + assert!(text.contains(LIST_TOOLS), "should point at the detail tool"); + } + + #[test] + fn execute_description_is_explicit_when_nothing_applies() { + let text = test_server(&[]).execute_description(); + assert!(text.contains("No MCP servers apply"), "{text}"); + } + + /// The model is shown TypeScript declarations, so it has to be told not + /// to reply in TypeScript. + #[test] + fn execute_description_warns_against_type_annotations() { + let text = test_server(&[]).execute_description(); + assert!(text.contains("no type annotations"), "{text}"); + } +} diff --git a/src/mcp/supervisor.rs b/src/mcp/supervisor.rs new file mode 100644 index 00000000..2e871a5e --- /dev/null +++ b/src/mcp/supervisor.rs @@ -0,0 +1,559 @@ +//! Keeping one backing server available. +//! +//! Servers are started on first use, not at session start: most sessions +//! touch few of the servers a workspace declares, and starting the rest buys +//! nothing. +//! +//! Restarting needs more care than a retry counter. A linear budget burns out +//! in seconds against a server that is briefly unavailable — blocked on a +//! build lock, say — so backoff is exponential. And a server that runs for +//! hours before dying once should not be treated as one that has never +//! worked, so a long-lived connection clears the count. +//! +//! What is *not* retried is the tool call. A call that dies mid-flight may +//! already have had its effect, so the failure is reported and the restart +//! happens on the next call instead. + +use std::time::{Duration, Instant}; + +use rmcp::model::Tool; +use serde_json::Value; + +use super::client::{BackingServer, ClientError, SpawnSpec}; + +/// How hard to try keeping a server up. +#[derive(Debug, Clone, Copy)] +pub struct RestartPolicy { + pub max_restarts: u32, + pub base_backoff: Duration, + pub max_backoff: Duration, + /// How long a connection must last before its failures are forgiven. + pub stable_reset: Duration, + /// How long a server gets to exit cleanly before being killed. + pub shutdown_grace: Duration, +} + +impl Default for RestartPolicy { + fn default() -> Self { + Self { + max_restarts: 5, + base_backoff: Duration::from_secs(1), + max_backoff: Duration::from_secs(60), + stable_reset: Duration::from_secs(300), + shutdown_grace: Duration::from_secs(5), + } + } +} + +/// Where a supervised server stands. +/// +/// Transitions consume the value, so a state cannot be reached from one that +/// does not lead to it. +enum State { + /// Not started. The normal resting state until a tool is called. + Cold, + Ready { + server: Box, + since: Instant, + }, + /// Out of restarts. Terminal for the session. + Failed { reason: String }, +} + +impl State { + fn name(&self) -> &'static str { + match self { + Self::Cold => "cold", + Self::Ready { .. } => "ready", + Self::Failed { .. } => "failed", + } + } +} + +pub struct Supervisor { + spec: SpawnSpec, + policy: RestartPolicy, + state: State, + /// Failed start attempts since the last success that lasted. + attempts: u32, + /// When the next start attempt may happen. + retry_after: Option, +} + +impl Supervisor { + /// Register a server without starting it. + pub fn new(spec: SpawnSpec, policy: RestartPolicy) -> Self { + Self { + spec, + policy, + state: State::Cold, + attempts: 0, + retry_after: None, + } + } + + pub fn name(&self) -> &str { + &self.spec.name + } + + /// Current state, for reporting. + pub fn state_name(&self) -> &'static str { + self.state.name() + } + + /// Whether a process is running right now. + pub fn is_running(&self) -> bool { + matches!(self.state, State::Ready { .. }) + } + + pub async fn list_tools(&mut self) -> Result, ClientError> { + self.ensure_ready().await?; + let State::Ready { server, .. } = &self.state else { + unreachable!("ensure_ready returned without a connection") + }; + let outcome = server.list_tools().await; + self.note_connection_health(&outcome); + outcome + } + + /// Call a tool, starting the server if needed. + /// + /// A call that fails because the connection broke is **not** retried: it + /// may already have taken effect, and repeating it could take effect + /// twice. The server is left cold so the next call starts a new one. + pub async fn call( + &mut self, + tool: &str, + args: Value, + timeout: Duration, + ) -> Result { + self.ensure_ready().await?; + let State::Ready { server, .. } = &self.state else { + unreachable!("ensure_ready returned without a connection") + }; + let outcome = server.call(tool, args, timeout).await; + self.note_connection_health(&outcome); + outcome + } + + /// A broken connection means the process is gone; drop it so the next + /// call starts a fresh one. A tool's own failure says nothing about the + /// connection. + fn note_connection_health(&mut self, outcome: &Result) { + if !matches!(outcome, Err(ClientError::Protocol { .. })) { + return; + } + let State::Ready { since, .. } = &self.state else { + return; + }; + // A connection that lasted has earned a clean slate. + if since.elapsed() >= self.policy.stable_reset { + self.attempts = 0; + } + self.state = State::Cold; + } + + async fn ensure_ready(&mut self) -> Result<(), ClientError> { + match &self.state { + State::Ready { .. } => return Ok(()), + State::Failed { reason } => { + return Err(ClientError::StartupFailed { + server: self.spec.name.clone(), + detail: reason.clone(), + }); + } + State::Cold => {} + } + + if let Some(at) = self.retry_after { + let now = Instant::now(); + if now < at { + tokio::time::sleep(at - now).await; + } + } + + match BackingServer::spawn(&self.spec).await { + Ok(server) => { + self.state = State::Ready { + server: Box::new(server), + since: Instant::now(), + }; + self.retry_after = None; + Ok(()) + } + Err(e) => { + self.attempts += 1; + if self.attempts > self.policy.max_restarts { + self.state = State::Failed { + reason: e.to_string(), + }; + } else { + self.retry_after = Some(Instant::now() + self.backoff()); + } + Err(e) + } + } + } + + /// Exponential, capped. A failing server should not be hammered, but a + /// transient failure should not cost a whole session either. + fn backoff(&self) -> Duration { + let shift = self.attempts.saturating_sub(1).min(16); + self.policy + .base_backoff + .saturating_mul(1u32 << shift) + .min(self.policy.max_backoff) + } + + /// Close the connection, giving the server a chance to exit cleanly + /// before its process group is killed on drop. + pub async fn shutdown(&mut self) { + let previous = std::mem::replace(&mut self.state, State::Cold); + if let State::Ready { server, .. } = previous { + // A hung shutdown must not wedge the session; the drop that + // follows kills the process group regardless. + let _ = tokio::time::timeout(self.policy.shutdown_grace, server.shutdown()).await; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::path::PathBuf; + + /// Locate the mock server built alongside the test binary. + fn mock_binary() -> PathBuf { + let mut dir = std::env::current_exe().expect("test binary path"); + dir.pop(); // deps/ + if dir.ends_with("deps") { + dir.pop(); + } + dir.join("examples").join("mock-mcp-server") + } + + struct Fixture { + _dir: tempfile::TempDir, + config: PathBuf, + } + + fn fixture(config: Value) -> Fixture { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("mock.json"); + std::fs::write(&path, config.to_string()).unwrap(); + Fixture { + _dir: dir, + config: path, + } + } + + fn spec(fixture: &Fixture, startup_timeout: Duration) -> SpawnSpec { + SpawnSpec { + name: "mock".to_string(), + command: mock_binary(), + args: vec!["--config".to_string(), fixture.config.display().to_string()], + env: Vec::new(), + cwd: None, + startup_timeout, + } + } + + fn fast_policy() -> RestartPolicy { + RestartPolicy { + base_backoff: Duration::from_millis(10), + max_backoff: Duration::from_millis(50), + shutdown_grace: Duration::from_millis(500), + ..RestartPolicy::default() + } + } + + fn echo_config() -> Value { + json!({ + "name": "mock", + "tools": [{"name": "echo", "behavior": {"kind": "echo"}}] + }) + } + + #[tokio::test(flavor = "multi_thread")] + async fn server_is_not_started_until_a_tool_is_called() { + let f = fixture(echo_config()); + let mut sup = Supervisor::new(spec(&f, Duration::from_secs(10)), fast_policy()); + + assert_eq!(sup.state_name(), "cold"); + assert!(!sup.is_running()); + + sup.call("echo", json!({"a": 1}), Duration::from_secs(5)) + .await + .unwrap(); + assert!(sup.is_running(), "first call should have started it"); + sup.shutdown().await; + } + + #[tokio::test(flavor = "multi_thread")] + async fn calls_reach_the_server() { + let f = fixture(echo_config()); + let mut sup = Supervisor::new(spec(&f, Duration::from_secs(10)), fast_policy()); + + let out = sup + .call("echo", json!({"a": 1}), Duration::from_secs(5)) + .await + .unwrap(); + assert_eq!(out, json!({"a": 1})); + sup.shutdown().await; + } + + #[tokio::test(flavor = "multi_thread")] + async fn tools_are_listed() { + let f = fixture(echo_config()); + let mut sup = Supervisor::new(spec(&f, Duration::from_secs(10)), fast_policy()); + + let tools = sup.list_tools().await.unwrap(); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].name.as_ref(), "echo"); + sup.shutdown().await; + } + + /// A server that fails a few times and then works should end up working. + #[tokio::test(flavor = "multi_thread")] + async fn transient_startup_failures_are_retried() { + let mut config = echo_config(); + config["fail_startup_times"] = json!(2); + let f = fixture(config); + let mut sup = Supervisor::new(spec(&f, Duration::from_secs(10)), fast_policy()); + + for _ in 0..2 { + assert!( + sup.call("echo", json!({}), Duration::from_secs(5)) + .await + .is_err(), + "the first attempts are expected to fail" + ); + assert_eq!(sup.state_name(), "cold", "still retryable"); + } + + let out = sup + .call("echo", json!({"n": 1}), Duration::from_secs(5)) + .await; + assert!(out.is_ok(), "should recover once the server stops failing"); + sup.shutdown().await; + } + + /// A server that never starts must stop being retried, or every later + /// call pays the startup cost again. + #[tokio::test(flavor = "multi_thread")] + async fn permanent_startup_failure_becomes_terminal() { + let mut config = echo_config(); + config["fail_startup_times"] = json!(999); + let f = fixture(config); + let policy = RestartPolicy { + max_restarts: 2, + ..fast_policy() + }; + let mut sup = Supervisor::new(spec(&f, Duration::from_secs(10)), policy); + + for _ in 0..3 { + let _ = sup.call("echo", json!({}), Duration::from_secs(5)).await; + } + assert_eq!( + sup.state_name(), + "failed", + "should give up after exhausting its restarts" + ); + + // A terminal server answers immediately rather than trying again. + let started = Instant::now(); + let err = sup + .call("echo", json!({}), Duration::from_secs(5)) + .await + .unwrap_err(); + assert!(matches!(err, ClientError::StartupFailed { .. })); + assert!( + started.elapsed() < Duration::from_secs(1), + "a failed server should not be retried" + ); + } + + /// Backoff grows so a crash-looping server is not hammered, but stays + /// capped so recovery is not delayed for minutes. + #[test] + fn backoff_grows_and_is_capped() { + let mut sup = Supervisor::new( + SpawnSpec { + name: "x".into(), + command: "true".into(), + args: vec![], + env: vec![], + cwd: None, + startup_timeout: Duration::from_secs(1), + }, + RestartPolicy { + base_backoff: Duration::from_secs(1), + max_backoff: Duration::from_secs(8), + ..RestartPolicy::default() + }, + ); + + let mut seen = Vec::new(); + for attempt in 1..=6 { + sup.attempts = attempt; + seen.push(sup.backoff()); + } + assert_eq!( + seen, + vec![ + Duration::from_secs(1), + Duration::from_secs(2), + Duration::from_secs(4), + Duration::from_secs(8), + Duration::from_secs(8), + Duration::from_secs(8), + ] + ); + } + + /// A call that dies mid-flight may already have taken effect, so it is + /// reported rather than repeated. The next call starts a fresh server. + #[tokio::test(flavor = "multi_thread")] + async fn a_call_lost_to_a_crash_is_not_repeated() { + let f = fixture(json!({ + "name": "mock", + "tools": [{"name": "boom", "behavior": {"kind": "crash_after", "calls": 1}}] + })); + let mut sup = Supervisor::new(spec(&f, Duration::from_secs(10)), fast_policy()); + + let first = sup.call("boom", json!({}), Duration::from_secs(5)).await; + assert!(first.is_ok(), "the first call is served: {first:?}"); + + let second = sup.call("boom", json!({}), Duration::from_secs(5)).await; + assert!( + matches!(second, Err(ClientError::Protocol { .. })), + "a lost call is reported, got: {second:?}" + ); + assert_eq!( + sup.state_name(), + "cold", + "the dead process should be dropped, not reused" + ); + sup.shutdown().await; + } + + /// A tool with no parameters must still be sent an object. The spec makes + /// `arguments` optional, but zod-based servers reject an absent field, and + /// those are a large share of what is published. + #[tokio::test(flavor = "multi_thread")] + async fn a_tool_taking_no_arguments_is_sent_an_empty_object() { + let f = fixture(json!({ + "name": "mock", + "tools": [{"name": "ping", "behavior": {"kind": "echo"}}] + })); + let mut sup = Supervisor::new(spec(&f, Duration::from_secs(10)), fast_policy()); + + // `echo` returns whatever arguments it received. + let out = sup + .call("ping", Value::Null, Duration::from_secs(5)) + .await + .unwrap(); + assert_eq!(out, json!({}), "an object, not null"); + sup.shutdown().await; + } + + /// A server that reads the project it serves needs to run inside it. + /// Four of the seven client config formats carry this for that reason. + /// + /// Proved with a *relative* config path: the server can only find its + /// config if the working directory was applied. + #[tokio::test(flavor = "multi_thread")] + async fn a_server_runs_in_its_configured_directory() { + let f = fixture(json!({ + "name": "mock", + "tools": [{"name": "echo", "behavior": {"kind": "echo"}}] + })); + let dir = f.config.parent().expect("fixture dir").to_path_buf(); + + let relative = SpawnSpec { + name: "mock".to_string(), + command: mock_binary(), + args: vec!["--config".to_string(), "mock.json".to_string()], + env: Vec::new(), + cwd: Some(dir.clone()), + startup_timeout: Duration::from_secs(10), + }; + let mut sup = Supervisor::new(relative, fast_policy()); + sup.call("echo", json!({"ok": 1}), Duration::from_secs(5)) + .await + .expect("a relative config resolves only from the right directory"); + sup.shutdown().await; + + // Without it, the same relative path cannot be found. + let mut without_cwd = Supervisor::new( + SpawnSpec { + name: "mock".to_string(), + command: mock_binary(), + args: vec!["--config".to_string(), "mock.json".to_string()], + env: Vec::new(), + cwd: None, + startup_timeout: Duration::from_secs(10), + }, + fast_policy(), + ); + assert!( + without_cwd + .call("echo", json!({}), Duration::from_secs(5)) + .await + .is_err(), + "the relative path should not resolve from the test's own directory" + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn a_hanging_tool_times_out() { + let f = fixture(json!({ + "name": "mock", + "tools": [{"name": "slow", "behavior": {"kind": "hang"}}] + })); + let mut sup = Supervisor::new(spec(&f, Duration::from_secs(10)), fast_policy()); + + let err = sup + .call("slow", json!({}), Duration::from_millis(300)) + .await + .unwrap_err(); + assert!( + matches!(err, ClientError::CallTimeout { .. }), + "got: {err:?}" + ); + sup.shutdown().await; + } + + #[tokio::test(flavor = "multi_thread")] + async fn a_slow_start_times_out() { + let mut config = echo_config(); + config["startup_delay_ms"] = json!(5000); + let f = fixture(config); + let mut sup = Supervisor::new(spec(&f, Duration::from_millis(200)), fast_policy()); + + let err = sup + .call("echo", json!({}), Duration::from_secs(5)) + .await + .unwrap_err(); + assert!( + matches!(err, ClientError::StartupTimeout { .. }), + "got: {err:?}" + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn shutdown_leaves_no_connection() { + let f = fixture(echo_config()); + let mut sup = Supervisor::new(spec(&f, Duration::from_secs(10)), fast_policy()); + + sup.call("echo", json!({}), Duration::from_secs(5)) + .await + .unwrap(); + assert!(sup.is_running()); + + sup.shutdown().await; + assert!(!sup.is_running()); + assert_eq!(sup.state_name(), "cold"); + } +} diff --git a/src/mcp/testdata/constructs.d.ts b/src/mcp/testdata/constructs.d.ts new file mode 100644 index 00000000..34ea605c --- /dev/null +++ b/src/mcp/testdata/constructs.d.ts @@ -0,0 +1,59 @@ +type Config = { + host: string; +}; + +type Json = string | Json[]; + +type Merged = { + a?: string; +} & { + b?: number; +}; + +type Shape = { + Circle: number; +} | { + Square: number; +}; + +declare const constructs: { + /** Input is a $ref to a named model, the shape pydantic emits for a root model. */ + pydantic_root_model(): Promise; + /** A serde externally-tagged enum, the most common schemars-generated $def. */ + schemars_tagged_enum(params: { + shape: Shape; + }): Promise; + /** oneOf beside properties: the 'provide one of these' idiom. */ + either_path_or_url(params?: unknown): Promise; + /** allOf alongside local properties; both belong in the result. */ + all_of_with_siblings(params: { + shared: number; + }): Promise; + /** Defines #/$defs/Config. Generators re-emit $defs per tool, so the name is not unique across a server. */ + config_first(params: { + config: Config; + }): Promise; + /** Defines #/$defs/Config with a different body. Must not collapse into the first. */ + config_second(params: { + config: Config; + }): Promise; + /** properties without a type keyword; type is not required by JSON Schema. */ + no_declared_type(params: unknown): Promise; + /** The pydantic optional spelling. A control: this one already works. */ + nullable_via_any_of(params: { + revision: string | null; + }): Promise; + /** Both tuple spellings. zod emits the first on draft-07 targets and the second on 2020-12. */ + tuple_shapes(params?: { + draft07?: unknown[]; + modern?: never[]; + }): Promise; + /** Self-reference through a union, the shape a JSON-value type takes. */ + recursive_defs(params: { + tree: Json; + }): Promise; + /** A $def whose body is an intersection rather than a single object. */ + merged_object_def(params: { + merged: Merged; + }): Promise; +}; diff --git a/src/mcp/testdata/constructs.tools.json b/src/mcp/testdata/constructs.tools.json new file mode 100644 index 00000000..86612d3f --- /dev/null +++ b/src/mcp/testdata/constructs.tools.json @@ -0,0 +1,181 @@ +{ + "_comment": "Hand-built, unlike the rest of testdata/. Every construct here is one the captured corpus happens not to contain and the RFD nonetheless cites as motivation for hand-rolling the mapping: $ref/$defs, allOf/anyOf/oneOf, tuples, and type-less schemas. Grouped one tool per construct so a regression names itself.", + "tools": [ + { + "name": "pydantic_root_model", + "description": "Input is a $ref to a named model, the shape pydantic emits for a root model.", + "inputSchema": { + "$ref": "#/$defs/SearchInput", + "$defs": { + "SearchInput": { + "type": "object", + "properties": { + "query": { "type": "string" }, + "limit": { "type": "integer", "default": 10 } + }, + "required": ["query"] + } + } + } + }, + { + "name": "schemars_tagged_enum", + "description": "A serde externally-tagged enum, the most common schemars-generated $def.", + "inputSchema": { + "type": "object", + "properties": { "shape": { "$ref": "#/definitions/Shape" } }, + "required": ["shape"], + "definitions": { + "Shape": { + "oneOf": [ + { + "type": "object", + "properties": { "Circle": { "type": "number" } }, + "required": ["Circle"] + }, + { + "type": "object", + "properties": { "Square": { "type": "number" } }, + "required": ["Square"] + } + ] + } + } + } + }, + { + "name": "either_path_or_url", + "description": "oneOf beside properties: the 'provide one of these' idiom.", + "inputSchema": { + "type": "object", + "properties": { + "path": { "type": "string" }, + "url": { "type": "string" } + }, + "oneOf": [{ "required": ["path"] }, { "required": ["url"] }] + } + }, + { + "name": "all_of_with_siblings", + "description": "allOf alongside local properties; both belong in the result.", + "inputSchema": { + "type": "object", + "properties": { "local": { "type": "string" } }, + "required": ["local"], + "allOf": [ + { + "type": "object", + "properties": { "shared": { "type": "number" } }, + "required": ["shared"] + } + ] + } + }, + { + "name": "config_first", + "description": "Defines #/$defs/Config. Generators re-emit $defs per tool, so the name is not unique across a server.", + "inputSchema": { + "type": "object", + "properties": { "config": { "$ref": "#/$defs/Config" } }, + "required": ["config"], + "$defs": { + "Config": { + "type": "object", + "properties": { "host": { "type": "string" } }, + "required": ["host"] + } + } + } + }, + { + "name": "config_second", + "description": "Defines #/$defs/Config with a different body. Must not collapse into the first.", + "inputSchema": { + "type": "object", + "properties": { "config": { "$ref": "#/$defs/Config" } }, + "required": ["config"], + "$defs": { + "Config": { + "type": "object", + "properties": { "retries": { "type": "number" } }, + "required": ["retries"] + } + } + } + }, + { + "name": "no_declared_type", + "description": "properties without a type keyword; type is not required by JSON Schema.", + "inputSchema": { + "properties": { + "name": { "type": "string" }, + "tags": { "items": { "type": "string" } } + }, + "required": ["name"] + } + }, + { + "name": "nullable_via_any_of", + "description": "The pydantic optional spelling. A control: this one already works.", + "inputSchema": { + "type": "object", + "properties": { + "revision": { "anyOf": [{ "type": "string" }, { "type": "null" }] } + }, + "required": ["revision"] + } + }, + { + "name": "tuple_shapes", + "description": "Both tuple spellings. zod emits the first on draft-07 targets and the second on 2020-12.", + "inputSchema": { + "type": "object", + "properties": { + "draft07": { + "type": "array", + "items": [{ "type": "string" }, { "type": "number" }] + }, + "modern": { + "type": "array", + "prefixItems": [{ "type": "string" }, { "type": "number" }], + "items": false + } + } + } + }, + { + "name": "recursive_defs", + "description": "Self-reference through a union, the shape a JSON-value type takes.", + "inputSchema": { + "type": "object", + "properties": { "tree": { "$ref": "#/$defs/Json" } }, + "required": ["tree"], + "$defs": { + "Json": { + "anyOf": [ + { "type": "string" }, + { "type": "array", "items": { "$ref": "#/$defs/Json" } } + ] + } + } + } + }, + { + "name": "merged_object_def", + "description": "A $def whose body is an intersection rather than a single object.", + "inputSchema": { + "type": "object", + "properties": { "merged": { "$ref": "#/$defs/Merged" } }, + "required": ["merged"], + "$defs": { + "Merged": { + "allOf": [ + { "type": "object", "properties": { "a": { "type": "string" } } }, + { "type": "object", "properties": { "b": { "type": "number" } } } + ] + } + } + } + } + ] +} diff --git a/src/mcp/testdata/everything.d.ts b/src/mcp/testdata/everything.d.ts new file mode 100644 index 00000000..0b21aafc --- /dev/null +++ b/src/mcp/testdata/everything.d.ts @@ -0,0 +1,143 @@ +declare const everything: { + /** Echoes back the input string */ + echo(params: { + /** Message to echo */ + message: string; + }): Promise; + /** Demonstrates how annotations can be used to provide metadata about content. */ + "get-annotated-message"(params: { + /** Whether to include an example image */ + includeImage?: boolean; + /** Type of message to demonstrate different annotation patterns */ + messageType: "error" | "success" | "debug"; + }): Promise; + /** Alias for get-annotated-message. */ + get_annotated_message(params: { + /** Whether to include an example image */ + includeImage?: boolean; + /** Type of message to demonstrate different annotation patterns */ + messageType: "error" | "success" | "debug"; + }): Promise; + /** Returns all environment variables, helpful for debugging MCP server configuration */ + "get-env"(): Promise; + /** Alias for get-env. */ + get_env(): Promise; + /** Returns up to ten resource links that reference different types of resources */ + "get-resource-links"(params?: { + /** Number of resource links to return (1-10) */ + count?: number; + }): Promise; + /** Alias for get-resource-links. */ + get_resource_links(params?: { + /** Number of resource links to return (1-10) */ + count?: number; + }): Promise; + /** Returns a resource reference that can be used by MCP clients */ + "get-resource-reference"(params?: { + /** ID of the text resource to fetch */ + resourceId?: number; + resourceType?: "Text" | "Blob"; + }): Promise; + /** Alias for get-resource-reference. */ + get_resource_reference(params?: { + /** ID of the text resource to fetch */ + resourceId?: number; + resourceType?: "Text" | "Blob"; + }): Promise; + /** Returns structured content along with an output schema for client data validation */ + "get-structured-content"(params: { + /** Choose city */ + location: "New York" | "Chicago" | "Los Angeles"; + }): Promise<{ + /** Weather conditions description */ + conditions: string; + /** Humidity percentage */ + humidity: number; + /** Temperature in celsius */ + temperature: number; + }>; + /** Alias for get-structured-content. */ + get_structured_content(params: { + /** Choose city */ + location: "New York" | "Chicago" | "Los Angeles"; + }): Promise<{ + /** Weather conditions description */ + conditions: string; + /** Humidity percentage */ + humidity: number; + /** Temperature in celsius */ + temperature: number; + }>; + /** Returns the sum of two numbers */ + "get-sum"(params: { + /** First number */ + a: number; + /** Second number */ + b: number; + }): Promise; + /** Alias for get-sum. */ + get_sum(params: { + /** First number */ + a: number; + /** Second number */ + b: number; + }): Promise; + /** Returns a tiny MCP logo image. */ + "get-tiny-image"(): Promise; + /** Alias for get-tiny-image. */ + get_tiny_image(): Promise; + /** Compresses a single file using gzip compression. Depending upon the selected output type, returns either the compressed data as a gzipped resource or a resource link, allowing it to be downloaded in a subsequent request during the current session. */ + "gzip-file-as-resource"(params?: { + /** URL or data URI of the file content to compress */ + data?: string; + /** Name of the output file */ + name?: string; + /** How the resulting gzipped file should be returned. 'resourceLink' returns a link to a resource that can be read later, 'resource' returns a full resource object. */ + outputType?: "resourceLink" | "resource"; + }): Promise; + /** Alias for gzip-file-as-resource. */ + gzip_file_as_resource(params?: { + /** URL or data URI of the file content to compress */ + data?: string; + /** Name of the output file */ + name?: string; + /** How the resulting gzipped file should be returned. 'resourceLink' returns a link to a resource that can be read later, 'resource' returns a full resource object. */ + outputType?: "resourceLink" | "resource"; + }): Promise; + /** Toggles simulated, random-leveled logging on or off. */ + "toggle-simulated-logging"(): Promise; + /** Alias for toggle-simulated-logging. */ + toggle_simulated_logging(): Promise; + /** Toggles simulated resource subscription updates on or off. */ + "toggle-subscriber-updates"(): Promise; + /** Alias for toggle-subscriber-updates. */ + toggle_subscriber_updates(): Promise; + /** Demonstrates a long running operation with progress updates. */ + "trigger-long-running-operation"(params?: { + /** Duration of the operation in seconds */ + duration?: number; + /** Number of steps in the operation */ + steps?: number; + }): Promise; + /** Alias for trigger-long-running-operation. */ + trigger_long_running_operation(params?: { + /** Duration of the operation in seconds */ + duration?: number; + /** Number of steps in the operation */ + steps?: number; + }): Promise; + /** Simulates a deep research operation that gathers, analyzes, and synthesizes information. Demonstrates MCP task-based operations with progress through multiple stages. If 'ambiguous' is true and client supports elicitation, sends an elicitation request for clarification. */ + "simulate-research-query"(params: { + /** Simulate an ambiguous query that requires clarification (triggers input_required status) */ + ambiguous?: boolean; + /** The research topic to investigate */ + topic: string; + }): Promise; + /** Alias for simulate-research-query. */ + simulate_research_query(params: { + /** Simulate an ambiguous query that requires clarification (triggers input_required status) */ + ambiguous?: boolean; + /** The research topic to investigate */ + topic: string; + }): Promise; +}; diff --git a/src/mcp/testdata/everything.tools.json b/src/mcp/testdata/everything.tools.json new file mode 100644 index 00000000..215ee3d8 --- /dev/null +++ b/src/mcp/testdata/everything.tools.json @@ -0,0 +1,394 @@ +{ + "tools": [ + { + "name": "echo", + "title": "Echo Tool", + "description": "Echoes back the input string", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Message to echo" + } + }, + "required": [ + "message" + ] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "get-annotated-message", + "title": "Get Annotated Message Tool", + "description": "Demonstrates how annotations can be used to provide metadata about content.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "messageType": { + "type": "string", + "enum": [ + "error", + "success", + "debug" + ], + "description": "Type of message to demonstrate different annotation patterns" + }, + "includeImage": { + "default": false, + "description": "Whether to include an example image", + "type": "boolean" + } + }, + "required": [ + "messageType" + ] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "get-env", + "title": "Print Environment Tool", + "description": "Returns all environment variables, helpful for debugging MCP server configuration", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {} + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "get-resource-links", + "title": "Get Resource Links Tool", + "description": "Returns up to ten resource links that reference different types of resources", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "count": { + "default": 3, + "description": "Number of resource links to return (1-10)", + "type": "number", + "minimum": 1, + "maximum": 10 + } + } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "get-resource-reference", + "title": "Get Resource Reference Tool", + "description": "Returns a resource reference that can be used by MCP clients", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "resourceType": { + "default": "Text", + "type": "string", + "enum": [ + "Text", + "Blob" + ] + }, + "resourceId": { + "default": 1, + "description": "ID of the text resource to fetch", + "type": "number" + } + } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "get-structured-content", + "title": "Get Structured Content Tool", + "description": "Returns structured content along with an output schema for client data validation", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "location": { + "type": "string", + "enum": [ + "New York", + "Chicago", + "Los Angeles" + ], + "description": "Choose city" + } + }, + "required": [ + "location" + ] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "temperature": { + "type": "number", + "description": "Temperature in celsius" + }, + "conditions": { + "type": "string", + "description": "Weather conditions description" + }, + "humidity": { + "type": "number", + "description": "Humidity percentage" + } + }, + "required": [ + "temperature", + "conditions", + "humidity" + ], + "additionalProperties": false + } + }, + { + "name": "get-sum", + "title": "Get Sum Tool", + "description": "Returns the sum of two numbers", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "a": { + "type": "number", + "description": "First number" + }, + "b": { + "type": "number", + "description": "Second number" + } + }, + "required": [ + "a", + "b" + ] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "get-tiny-image", + "title": "Get Tiny Image Tool", + "description": "Returns a tiny MCP logo image.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {} + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "gzip-file-as-resource", + "title": "GZip File as Resource Tool", + "description": "Compresses a single file using gzip compression. Depending upon the selected output type, returns either the compressed data as a gzipped resource or a resource link, allowing it to be downloaded in a subsequent request during the current session.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "name": { + "default": "README.md.gz", + "type": "string", + "description": "Name of the output file" + }, + "data": { + "default": "https://raw.githubusercontent.com/modelcontextprotocol/servers/refs/heads/main/README.md", + "type": "string", + "format": "uri", + "description": "URL or data URI of the file content to compress" + }, + "outputType": { + "default": "resourceLink", + "description": "How the resulting gzipped file should be returned. 'resourceLink' returns a link to a resource that can be read later, 'resource' returns a full resource object.", + "type": "string", + "enum": [ + "resourceLink", + "resource" + ] + } + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "toggle-simulated-logging", + "title": "Toggle Simulated Logging", + "description": "Toggles simulated, random-leveled logging on or off.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {} + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "toggle-subscriber-updates", + "title": "Toggle Subscriber Updates", + "description": "Toggles simulated resource subscription updates on or off.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {} + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "trigger-long-running-operation", + "title": "Trigger Long Running Operation Tool", + "description": "Demonstrates a long running operation with progress updates.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "duration": { + "default": 10, + "description": "Duration of the operation in seconds", + "type": "number" + }, + "steps": { + "default": 5, + "description": "Number of steps in the operation", + "type": "number" + } + } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "simulate-research-query", + "title": "Simulate Research Query", + "description": "Simulates a deep research operation that gathers, analyzes, and synthesizes information. Demonstrates MCP task-based operations with progress through multiple stages. If 'ambiguous' is true and client supports elicitation, sends an elicitation request for clarification.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "The research topic to investigate" + }, + "ambiguous": { + "default": false, + "description": "Simulate an ambiguous query that requires clarification (triggers input_required status)", + "type": "boolean" + } + }, + "required": [ + "topic" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "required" + } + } + ] +} diff --git a/src/mcp/testdata/filesystem.d.ts b/src/mcp/testdata/filesystem.d.ts new file mode 100644 index 00000000..fcbe6ae5 --- /dev/null +++ b/src/mcp/testdata/filesystem.d.ts @@ -0,0 +1,119 @@ +declare const filesystem: { + /** Read the complete contents of a file as text. DEPRECATED: Use read_text_file instead. */ + read_file(params: { + /** If provided, returns only the first N lines of the file */ + head?: number; + path: string; + /** If provided, returns only the last N lines of the file */ + tail?: number; + }): Promise<{ + content: string; + }>; + /** Read the complete contents of a file from the file system as text. Handles various text encodings and provides detailed error messages if the file cannot be read. Use this tool when you need to examine the contents of a single file. Use the 'head' parameter to read only the first N lines of a file, or the 'tail' parameter to read only the last N lines of a file. Operates on the file as text regardless of extension. Only works within allowed directories. */ + read_text_file(params: { + /** If provided, returns only the first N lines of the file */ + head?: number; + path: string; + /** If provided, returns only the last N lines of the file */ + tail?: number; + }): Promise<{ + content: string; + }>; + /** Read a file and return it as a base64-encoded content block with its MIME type. Image and audio files are returned as image/audio content; any other file type is returned as an embedded resource. Only works within allowed directories. */ + read_media_file(params: { + path: string; + }): Promise<{ + content: ({ + data: string; + mimeType: string; + type: "image" | "audio"; + } | { + resource: { + blob: string; + mimeType?: string; + uri: string; + }; + type: "resource"; + })[]; + }>; + /** Read the contents of multiple files simultaneously. This is more efficient than reading files one by one when you need to analyze or compare multiple files. Each file's content is returned with its path as a reference. Failed reads for individual files won't stop the entire operation. Only works within allowed directories. */ + read_multiple_files(params: { + /** Array of file paths to read. Each path must be a string pointing to a valid file within allowed directories. */ + paths: string[]; + }): Promise<{ + content: string; + }>; + /** Create a new file or completely overwrite an existing file with new content. Use with caution as it will overwrite existing files without warning. Handles text content with proper encoding. Only works within allowed directories. */ + write_file(params: { + content: string; + path: string; + }): Promise<{ + content: string; + }>; + /** Make line-based edits to a text file. Each edit replaces exact line sequences with new content. Returns a git-style diff showing the changes made. Only works within allowed directories. */ + edit_file(params: { + /** Preview changes using git-style diff format */ + dryRun?: boolean; + edits: { + /** Text to replace with */ + newText: string; + /** Text to search for - must match exactly */ + oldText: string; + }[]; + path: string; + }): Promise<{ + content: string; + }>; + /** Create a new directory or ensure a directory exists. Can create multiple nested directories in one operation. If the directory already exists, this operation will succeed silently. Perfect for setting up directory structures for projects or ensuring required paths exist. Only works within allowed directories. */ + create_directory(params: { + path: string; + }): Promise<{ + content: string; + }>; + /** Get a detailed listing of all files and directories in a specified path. Results clearly distinguish between files and directories with [FILE] and [DIR] prefixes. This tool is essential for understanding directory structure and finding specific files within a directory. Only works within allowed directories. */ + list_directory(params: { + path: string; + }): Promise<{ + content: string; + }>; + /** Get a detailed listing of all files and directories in a specified path, including sizes. Results clearly distinguish between files and directories with [FILE] and [DIR] prefixes. This tool is useful for understanding directory structure and finding specific files within a directory. Only works within allowed directories. */ + list_directory_with_sizes(params: { + path: string; + /** Sort entries by name or size */ + sortBy?: "name" | "size"; + }): Promise<{ + content: string; + }>; + /** Get a recursive tree view of files and directories as a JSON structure. Each entry includes 'name', 'type' (file/directory), and 'children' for directories. Files have no children array, while directories always have a children array (which may be empty). The output is formatted with 2-space indentation for readability. Only works within allowed directories. */ + directory_tree(params: { + excludePatterns?: string[]; + path: string; + }): Promise<{ + content: string; + }>; + /** Move or rename files and directories. Can move files between directories and rename them in a single operation. If the destination exists, the operation will fail. Works across different directories and can be used for simple renaming within the same directory. Both source and destination must be within allowed directories. */ + move_file(params: { + destination: string; + source: string; + }): Promise<{ + content: string; + }>; + /** Recursively search for files and directories matching a pattern. The patterns should be glob-style patterns that match paths relative to the working directory. Use pattern like '*.ext' to match files in current directory, and '** /*.ext' to match files in all subdirectories. Returns full paths to all matching items. Great for finding files when you don't know their exact location. Only searches within allowed directories. */ + search_files(params: { + excludePatterns?: string[]; + path: string; + pattern: string; + }): Promise<{ + content: string; + }>; + /** Retrieve detailed metadata about a file or directory. Returns comprehensive information including size, creation time, last modified time, permissions, and type. This tool is perfect for understanding file characteristics without reading the actual content. Only works within allowed directories. */ + get_file_info(params: { + path: string; + }): Promise<{ + content: string; + }>; + /** Returns the list of directories that this server is allowed to access. Subdirectories within these allowed directories are also accessible. Use this to understand which directories and their nested paths are available before trying to access files. */ + list_allowed_directories(): Promise<{ + content: string; + }>; +}; diff --git a/src/mcp/testdata/filesystem.tools.json b/src/mcp/testdata/filesystem.tools.json new file mode 100644 index 00000000..32f3b838 --- /dev/null +++ b/src/mcp/testdata/filesystem.tools.json @@ -0,0 +1,666 @@ +{ + "tools": [ + { + "name": "read_file", + "title": "Read File (Deprecated)", + "description": "Read the complete contents of a file as text. DEPRECATED: Use read_text_file instead.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "tail": { + "description": "If provided, returns only the last N lines of the file", + "type": "number" + }, + "head": { + "description": "If provided, returns only the first N lines of the file", + "type": "number" + } + }, + "required": [ + "path" + ] + }, + "annotations": { + "readOnlyHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ], + "additionalProperties": false + } + }, + { + "name": "read_text_file", + "title": "Read Text File", + "description": "Read the complete contents of a file from the file system as text. Handles various text encodings and provides detailed error messages if the file cannot be read. Use this tool when you need to examine the contents of a single file. Use the 'head' parameter to read only the first N lines of a file, or the 'tail' parameter to read only the last N lines of a file. Operates on the file as text regardless of extension. Only works within allowed directories.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "tail": { + "description": "If provided, returns only the last N lines of the file", + "type": "number" + }, + "head": { + "description": "If provided, returns only the first N lines of the file", + "type": "number" + } + }, + "required": [ + "path" + ] + }, + "annotations": { + "readOnlyHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ], + "additionalProperties": false + } + }, + { + "name": "read_media_file", + "title": "Read Media File", + "description": "Read a file and return it as a base64-encoded content block with its MIME type. Image and audio files are returned as image/audio content; any other file type is returned as an embedded resource. Only works within allowed directories.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] + }, + "annotations": { + "readOnlyHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "image", + "audio" + ] + }, + "data": { + "type": "string" + }, + "mimeType": { + "type": "string" + } + }, + "required": [ + "type", + "data", + "mimeType" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "resource" + }, + "resource": { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "blob": { + "type": "string" + } + }, + "required": [ + "uri", + "blob" + ], + "additionalProperties": false + } + }, + "required": [ + "type", + "resource" + ], + "additionalProperties": false + } + ] + } + } + }, + "required": [ + "content" + ], + "additionalProperties": false + } + }, + { + "name": "read_multiple_files", + "title": "Read Multiple Files", + "description": "Read the contents of multiple files simultaneously. This is more efficient than reading files one by one when you need to analyze or compare multiple files. Each file's content is returned with its path as a reference. Failed reads for individual files won't stop the entire operation. Only works within allowed directories.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "paths": { + "minItems": 1, + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of file paths to read. Each path must be a string pointing to a valid file within allowed directories." + } + }, + "required": [ + "paths" + ] + }, + "annotations": { + "readOnlyHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ], + "additionalProperties": false + } + }, + { + "name": "write_file", + "title": "Write File", + "description": "Create a new file or completely overwrite an existing file with new content. Use with caution as it will overwrite existing files without warning. Handles text content with proper encoding. Only works within allowed directories.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "content": { + "type": "string" + } + }, + "required": [ + "path", + "content" + ] + }, + "annotations": { + "readOnlyHint": false, + "idempotentHint": true, + "destructiveHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ], + "additionalProperties": false + } + }, + { + "name": "edit_file", + "title": "Edit File", + "description": "Make line-based edits to a text file. Each edit replaces exact line sequences with new content. Returns a git-style diff showing the changes made. Only works within allowed directories.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "edits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "oldText": { + "type": "string", + "description": "Text to search for - must match exactly" + }, + "newText": { + "type": "string", + "description": "Text to replace with" + } + }, + "required": [ + "oldText", + "newText" + ] + } + }, + "dryRun": { + "default": false, + "description": "Preview changes using git-style diff format", + "type": "boolean" + } + }, + "required": [ + "path", + "edits" + ] + }, + "annotations": { + "readOnlyHint": false, + "idempotentHint": false, + "destructiveHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ], + "additionalProperties": false + } + }, + { + "name": "create_directory", + "title": "Create Directory", + "description": "Create a new directory or ensure a directory exists. Can create multiple nested directories in one operation. If the directory already exists, this operation will succeed silently. Perfect for setting up directory structures for projects or ensuring required paths exist. Only works within allowed directories.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] + }, + "annotations": { + "readOnlyHint": false, + "idempotentHint": true, + "destructiveHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ], + "additionalProperties": false + } + }, + { + "name": "list_directory", + "title": "List Directory", + "description": "Get a detailed listing of all files and directories in a specified path. Results clearly distinguish between files and directories with [FILE] and [DIR] prefixes. This tool is essential for understanding directory structure and finding specific files within a directory. Only works within allowed directories.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] + }, + "annotations": { + "readOnlyHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ], + "additionalProperties": false + } + }, + { + "name": "list_directory_with_sizes", + "title": "List Directory with Sizes", + "description": "Get a detailed listing of all files and directories in a specified path, including sizes. Results clearly distinguish between files and directories with [FILE] and [DIR] prefixes. This tool is useful for understanding directory structure and finding specific files within a directory. Only works within allowed directories.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "sortBy": { + "default": "name", + "description": "Sort entries by name or size", + "type": "string", + "enum": [ + "name", + "size" + ] + } + }, + "required": [ + "path" + ] + }, + "annotations": { + "readOnlyHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ], + "additionalProperties": false + } + }, + { + "name": "directory_tree", + "title": "Directory Tree", + "description": "Get a recursive tree view of files and directories as a JSON structure. Each entry includes 'name', 'type' (file/directory), and 'children' for directories. Files have no children array, while directories always have a children array (which may be empty). The output is formatted with 2-space indentation for readability. Only works within allowed directories.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "excludePatterns": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "path" + ] + }, + "annotations": { + "readOnlyHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ], + "additionalProperties": false + } + }, + { + "name": "move_file", + "title": "Move File", + "description": "Move or rename files and directories. Can move files between directories and rename them in a single operation. If the destination exists, the operation will fail. Works across different directories and can be used for simple renaming within the same directory. Both source and destination must be within allowed directories.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "source": { + "type": "string" + }, + "destination": { + "type": "string" + } + }, + "required": [ + "source", + "destination" + ] + }, + "annotations": { + "readOnlyHint": false, + "idempotentHint": false, + "destructiveHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ], + "additionalProperties": false + } + }, + { + "name": "search_files", + "title": "Search Files", + "description": "Recursively search for files and directories matching a pattern. The patterns should be glob-style patterns that match paths relative to the working directory. Use pattern like '*.ext' to match files in current directory, and '**/*.ext' to match files in all subdirectories. Returns full paths to all matching items. Great for finding files when you don't know their exact location. Only searches within allowed directories.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "pattern": { + "type": "string" + }, + "excludePatterns": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "path", + "pattern" + ] + }, + "annotations": { + "readOnlyHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ], + "additionalProperties": false + } + }, + { + "name": "get_file_info", + "title": "Get File Info", + "description": "Retrieve detailed metadata about a file or directory. Returns comprehensive information including size, creation time, last modified time, permissions, and type. This tool is perfect for understanding file characteristics without reading the actual content. Only works within allowed directories.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] + }, + "annotations": { + "readOnlyHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ], + "additionalProperties": false + } + }, + { + "name": "list_allowed_directories", + "title": "List Allowed Directories", + "description": "Returns the list of directories that this server is allowed to access. Subdirectories within these allowed directories are also accessible. Use this to understand which directories and their nested paths are available before trying to access files.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {} + }, + "annotations": { + "readOnlyHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ], + "additionalProperties": false + } + } + ] +} diff --git a/src/mcp/testdata/memory.d.ts b/src/mcp/testdata/memory.d.ts new file mode 100644 index 00000000..abe7828e --- /dev/null +++ b/src/mcp/testdata/memory.d.ts @@ -0,0 +1,154 @@ +declare const memory: { + /** Create multiple new entities in the knowledge graph */ + create_entities(params: { + entities: { + /** The type of the entity */ + entityType: string; + /** The name of the entity */ + name: string; + /** An array of observation contents associated with the entity */ + observations: string[]; + }[]; + }): Promise<{ + entities: { + /** The type of the entity */ + entityType: string; + /** The name of the entity */ + name: string; + /** An array of observation contents associated with the entity */ + observations: string[]; + }[]; + }>; + /** Create multiple new relations between entities in the knowledge graph. Relations should be in active voice */ + create_relations(params: { + relations: { + /** The name of the entity where the relation starts */ + from: string; + /** The type of the relation */ + relationType: string; + /** The name of the entity where the relation ends */ + to: string; + }[]; + }): Promise<{ + relations: { + /** The name of the entity where the relation starts */ + from: string; + /** The type of the relation */ + relationType: string; + /** The name of the entity where the relation ends */ + to: string; + }[]; + }>; + /** Add new observations to existing entities in the knowledge graph */ + add_observations(params: { + observations: { + /** An array of observation contents to add */ + contents: string[]; + /** The name of the entity to add the observations to */ + entityName: string; + }[]; + }): Promise<{ + results: { + addedObservations: string[]; + entityName: string; + }[]; + }>; + /** Delete multiple entities and their associated relations from the knowledge graph */ + delete_entities(params: { + /** An array of entity names to delete */ + entityNames: string[]; + }): Promise<{ + message: string; + success: boolean; + }>; + /** Delete specific observations from entities in the knowledge graph */ + delete_observations(params: { + deletions: { + /** The name of the entity containing the observations */ + entityName: string; + /** An array of observations to delete */ + observations: string[]; + }[]; + }): Promise<{ + message: string; + success: boolean; + }>; + /** Delete multiple relations from the knowledge graph */ + delete_relations(params: { + /** An array of relations to delete */ + relations: { + /** The name of the entity where the relation starts */ + from: string; + /** The type of the relation */ + relationType: string; + /** The name of the entity where the relation ends */ + to: string; + }[]; + }): Promise<{ + message: string; + success: boolean; + }>; + /** Read the entire knowledge graph */ + read_graph(): Promise<{ + entities: { + /** The type of the entity */ + entityType: string; + /** The name of the entity */ + name: string; + /** An array of observation contents associated with the entity */ + observations: string[]; + }[]; + relations: { + /** The name of the entity where the relation starts */ + from: string; + /** The type of the relation */ + relationType: string; + /** The name of the entity where the relation ends */ + to: string; + }[]; + }>; + /** Search for nodes in the knowledge graph based on a query */ + search_nodes(params: { + /** The search query to match against entity names, types, and observation content */ + query: string; + }): Promise<{ + entities: { + /** The type of the entity */ + entityType: string; + /** The name of the entity */ + name: string; + /** An array of observation contents associated with the entity */ + observations: string[]; + }[]; + relations: { + /** The name of the entity where the relation starts */ + from: string; + /** The type of the relation */ + relationType: string; + /** The name of the entity where the relation ends */ + to: string; + }[]; + }>; + /** Open specific nodes in the knowledge graph by their names */ + open_nodes(params: { + /** An array of entity names to retrieve */ + names: string[]; + }): Promise<{ + entities: { + /** The type of the entity */ + entityType: string; + /** The name of the entity */ + name: string; + /** An array of observation contents associated with the entity */ + observations: string[]; + }[]; + relations: { + /** The name of the entity where the relation starts */ + from: string; + /** The type of the relation */ + relationType: string; + /** The name of the entity where the relation ends */ + to: string; + }[]; + }>; +}; diff --git a/src/mcp/testdata/memory.tools.json b/src/mcp/testdata/memory.tools.json new file mode 100644 index 00000000..0802be5a --- /dev/null +++ b/src/mcp/testdata/memory.tools.json @@ -0,0 +1,706 @@ +{ + "tools": [ + { + "name": "create_entities", + "title": "Create Entities", + "description": "Create multiple new entities in the knowledge graph", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "entities": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the entity" + }, + "entityType": { + "type": "string", + "description": "The type of the entity" + }, + "observations": { + "type": "array", + "items": { + "type": "string" + }, + "description": "An array of observation contents associated with the entity" + } + }, + "required": [ + "name", + "entityType", + "observations" + ] + } + } + }, + "required": [ + "entities" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "entities": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the entity" + }, + "entityType": { + "type": "string", + "description": "The type of the entity" + }, + "observations": { + "type": "array", + "items": { + "type": "string" + }, + "description": "An array of observation contents associated with the entity" + } + }, + "required": [ + "name", + "entityType", + "observations" + ], + "additionalProperties": false + } + } + }, + "required": [ + "entities" + ], + "additionalProperties": false + } + }, + { + "name": "create_relations", + "title": "Create Relations", + "description": "Create multiple new relations between entities in the knowledge graph. Relations should be in active voice", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "relations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "from": { + "type": "string", + "description": "The name of the entity where the relation starts" + }, + "to": { + "type": "string", + "description": "The name of the entity where the relation ends" + }, + "relationType": { + "type": "string", + "description": "The type of the relation" + } + }, + "required": [ + "from", + "to", + "relationType" + ] + } + } + }, + "required": [ + "relations" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "relations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "from": { + "type": "string", + "description": "The name of the entity where the relation starts" + }, + "to": { + "type": "string", + "description": "The name of the entity where the relation ends" + }, + "relationType": { + "type": "string", + "description": "The type of the relation" + } + }, + "required": [ + "from", + "to", + "relationType" + ], + "additionalProperties": false + } + } + }, + "required": [ + "relations" + ], + "additionalProperties": false + } + }, + { + "name": "add_observations", + "title": "Add Observations", + "description": "Add new observations to existing entities in the knowledge graph", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "observations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "entityName": { + "type": "string", + "description": "The name of the entity to add the observations to" + }, + "contents": { + "type": "array", + "items": { + "type": "string" + }, + "description": "An array of observation contents to add" + } + }, + "required": [ + "entityName", + "contents" + ] + } + } + }, + "required": [ + "observations" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "entityName": { + "type": "string" + }, + "addedObservations": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "entityName", + "addedObservations" + ], + "additionalProperties": false + } + } + }, + "required": [ + "results" + ], + "additionalProperties": false + } + }, + { + "name": "delete_entities", + "title": "Delete Entities", + "description": "Delete multiple entities and their associated relations from the knowledge graph", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "entityNames": { + "type": "array", + "items": { + "type": "string" + }, + "description": "An array of entity names to delete" + } + }, + "required": [ + "entityNames" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + } + }, + "required": [ + "success", + "message" + ], + "additionalProperties": false + } + }, + { + "name": "delete_observations", + "title": "Delete Observations", + "description": "Delete specific observations from entities in the knowledge graph", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "deletions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "entityName": { + "type": "string", + "description": "The name of the entity containing the observations" + }, + "observations": { + "type": "array", + "items": { + "type": "string" + }, + "description": "An array of observations to delete" + } + }, + "required": [ + "entityName", + "observations" + ] + } + } + }, + "required": [ + "deletions" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + } + }, + "required": [ + "success", + "message" + ], + "additionalProperties": false + } + }, + { + "name": "delete_relations", + "title": "Delete Relations", + "description": "Delete multiple relations from the knowledge graph", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "relations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "from": { + "type": "string", + "description": "The name of the entity where the relation starts" + }, + "to": { + "type": "string", + "description": "The name of the entity where the relation ends" + }, + "relationType": { + "type": "string", + "description": "The type of the relation" + } + }, + "required": [ + "from", + "to", + "relationType" + ] + }, + "description": "An array of relations to delete" + } + }, + "required": [ + "relations" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + } + }, + "required": [ + "success", + "message" + ], + "additionalProperties": false + } + }, + { + "name": "read_graph", + "title": "Read Graph", + "description": "Read the entire knowledge graph", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {} + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "entities": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the entity" + }, + "entityType": { + "type": "string", + "description": "The type of the entity" + }, + "observations": { + "type": "array", + "items": { + "type": "string" + }, + "description": "An array of observation contents associated with the entity" + } + }, + "required": [ + "name", + "entityType", + "observations" + ], + "additionalProperties": false + } + }, + "relations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "from": { + "type": "string", + "description": "The name of the entity where the relation starts" + }, + "to": { + "type": "string", + "description": "The name of the entity where the relation ends" + }, + "relationType": { + "type": "string", + "description": "The type of the relation" + } + }, + "required": [ + "from", + "to", + "relationType" + ], + "additionalProperties": false + } + } + }, + "required": [ + "entities", + "relations" + ], + "additionalProperties": false + } + }, + { + "name": "search_nodes", + "title": "Search Nodes", + "description": "Search for nodes in the knowledge graph based on a query", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The search query to match against entity names, types, and observation content" + } + }, + "required": [ + "query" + ] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "entities": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the entity" + }, + "entityType": { + "type": "string", + "description": "The type of the entity" + }, + "observations": { + "type": "array", + "items": { + "type": "string" + }, + "description": "An array of observation contents associated with the entity" + } + }, + "required": [ + "name", + "entityType", + "observations" + ], + "additionalProperties": false + } + }, + "relations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "from": { + "type": "string", + "description": "The name of the entity where the relation starts" + }, + "to": { + "type": "string", + "description": "The name of the entity where the relation ends" + }, + "relationType": { + "type": "string", + "description": "The type of the relation" + } + }, + "required": [ + "from", + "to", + "relationType" + ], + "additionalProperties": false + } + } + }, + "required": [ + "entities", + "relations" + ], + "additionalProperties": false + } + }, + { + "name": "open_nodes", + "title": "Open Nodes", + "description": "Open specific nodes in the knowledge graph by their names", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "names": { + "type": "array", + "items": { + "type": "string" + }, + "description": "An array of entity names to retrieve" + } + }, + "required": [ + "names" + ] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "entities": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the entity" + }, + "entityType": { + "type": "string", + "description": "The type of the entity" + }, + "observations": { + "type": "array", + "items": { + "type": "string" + }, + "description": "An array of observation contents associated with the entity" + } + }, + "required": [ + "name", + "entityType", + "observations" + ], + "additionalProperties": false + } + }, + "relations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "from": { + "type": "string", + "description": "The name of the entity where the relation starts" + }, + "to": { + "type": "string", + "description": "The name of the entity where the relation ends" + }, + "relationType": { + "type": "string", + "description": "The type of the relation" + } + }, + "required": [ + "from", + "to", + "relationType" + ], + "additionalProperties": false + } + } + }, + "required": [ + "entities", + "relations" + ], + "additionalProperties": false + } + } + ] +} diff --git a/src/mcp/testdata/playwright.d.ts b/src/mcp/testdata/playwright.d.ts new file mode 100644 index 00000000..d09e7d7f --- /dev/null +++ b/src/mcp/testdata/playwright.d.ts @@ -0,0 +1,211 @@ +declare const playwright: { + /** Close the page */ + browser_close(): Promise; + /** Resize the browser window */ + browser_resize(params: { + /** Height of the browser window */ + height: number; + /** Width of the browser window */ + width: number; + }): Promise; + /** Returns all console messages */ + browser_console_messages(params: { + /** Return all console messages since the beginning of the session, not just since the last navigation. Defaults to false. */ + all?: boolean; + /** Filename to save the console messages to. If not provided, messages are returned as text. */ + filename?: string; + /** Level of the console messages to return. Each level includes the messages of more severe levels. Defaults to "info". */ + level: "error" | "warning" | "info" | "debug"; + }): Promise; + /** Handle a dialog */ + browser_handle_dialog(params: { + /** Whether to accept the dialog. */ + accept: boolean; + /** The text of the prompt in case of a prompt dialog. */ + promptText?: string; + }): Promise; + /** Evaluate JavaScript expression on page or element */ + browser_evaluate(params: { + /** Human-readable element description used to obtain permission to interact with the element */ + element?: string; + /** Filename to save the result to. If not provided, result is returned as text. */ + filename?: string; + /** () => { /* code * / } or (element) => { /* code * / } when element is provided */ + function: string; + /** Exact target element reference from the page snapshot, or a unique element selector */ + target?: string; + }): Promise; + /** Upload one or multiple files */ + browser_file_upload(params?: { + /** The absolute paths to the files to upload. Can be single file or multiple files. If omitted, file chooser is cancelled. */ + paths?: string[]; + }): Promise; + /** Drop files or MIME-typed data onto an element, as if dragged from outside the page. At least one of "paths" or "data" must be provided. */ + browser_drop(params: { + /** Data to drop, as a map of MIME type to string value (e.g. {"text/plain": "hello", "text/uri-list": "https://example.com"}). */ + data?: Record; + /** Human-readable element description used to obtain permission to interact with the element */ + element?: string; + /** Absolute paths to files to drop onto the element. */ + paths?: string[]; + /** Exact target element reference from the page snapshot, or a unique element selector */ + target: string; + }): Promise; + /** Search the accessibility snapshot of the current page for text or a regular expression. Returns matching snapshot nodes with a few lines of surrounding context (like search snippets), each shown under its path from the root of the tree, which is cheaper than capturing the whole snapshot when you only need to locate an element and its ref. */ + browser_find(params?: { + /** Regular expression to search for in the page snapshot. Matching is case-sensitive by default; wrap the pattern in slashes to add flags, e.g. "/error/i" for case-insensitive. Provide either text or regex, not both. */ + regex?: string; + /** Plain text to search for in the page snapshot (case-insensitive substring match). Provide either text or regex, not both. */ + text?: string; + }): Promise; + /** Fill multiple form fields */ + browser_fill_form(params: { + /** Fields to fill in */ + fields: ({ + /** Human-readable element description used to obtain permission to interact with the element */ + element?: string; + /** Human-readable field name */ + name: string; + /** Exact target element reference from the page snapshot, or a unique element selector */ + target: string; + /** Type of the field */ + type: "textbox" | "checkbox" | "radio" | "combobox" | "slider"; + /** Value to fill in the field. If the field is a checkbox, the value should be `true` or `false`. If the field is a combobox, the value should be the text of the option. */ + value: string; + })[]; + }): Promise; + /** Press a key on the keyboard */ + browser_press_key(params: { + /** Name of the key to press or a character to generate, such as `ArrowLeft` or `a` */ + key: string; + }): Promise; + /** Type text into editable element */ + browser_type(params: { + /** Human-readable element description used to obtain permission to interact with the element */ + element?: string; + /** Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once. */ + slowly?: boolean; + /** Whether to submit entered text (press Enter after) */ + submit?: boolean; + /** Exact target element reference from the page snapshot, or a unique element selector */ + target: string; + /** Text to type into the element */ + text: string; + }): Promise; + /** Navigate to a URL */ + browser_navigate(params: { + /** The URL to navigate to */ + url: string; + }): Promise; + /** Go back to the previous page in the history */ + browser_navigate_back(): Promise; + /** Returns a numbered list of network requests since loading the page. Use browser_network_request with the number to get full details. */ + browser_network_requests(params: { + /** Filename to save the network requests to. If not provided, requests are returned as text. */ + filename?: string; + /** Only return requests whose URL matches this regexp (e.g. "/api/.*user"). */ + filter?: string; + /** Whether to include successful static resources like images, fonts, scripts, etc. Defaults to false. */ + static: boolean; + }): Promise; + /** Returns full details (headers and body) of a single network request, or a single part if `part` is set. Use the number from browser_network_requests. */ + browser_network_request(params: { + /** Filename to save the result to. If not provided, output is returned as text. */ + filename?: string; + /** 1-based index of the request, as printed by browser_network_requests. */ + index: number; + /** Return only this part of the request. Omit to return full details. */ + part?: "request-headers" | "request-body" | "response-headers" | "response-body"; + }): Promise; + /** Run a Playwright code snippet. Unsafe: executes arbitrary JavaScript in the Playwright server process and is RCE-equivalent. */ + browser_run_code_unsafe(params?: { + /** A JavaScript function containing Playwright code to execute. It will be invoked with a single argument, page, which you can use for any page interaction. For example: `async (page) => { await page.getByRole('button', { name: 'Submit' }).click(); return await page.title(); }` */ + code?: string; + /** Load code from the specified file. If both code and filename are provided, code will be ignored. */ + filename?: string; + }): Promise; + /** Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions. */ + browser_take_screenshot(params: { + /** Human-readable element description used to obtain permission to interact with the element */ + element?: string; + /** File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified. Prefer relative file names to stay within the output directory. */ + filename?: string; + /** When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Cannot be used with element screenshots. */ + fullPage?: boolean; + /** Image resolution scale. "css" produces a screenshot sized in CSS pixels (smaller, consistent across devices). "device" produces a high-resolution screenshot using device pixels (larger, accounts for the device pixel ratio). Default is css. */ + scale: "css" | "device"; + /** Exact target element reference from the page snapshot, or a unique element selector */ + target?: string; + /** Image format for the screenshot. Default is png. */ + type: "png" | "jpeg"; + }): Promise; + /** Capture accessibility snapshot of the current page, this is better than screenshot */ + browser_snapshot(params?: { + /** Include each element's bounding box as [box=x,y,width,height] in the snapshot. Coordinates are viewport-relative, in CSS pixels (Element.getBoundingClientRect) */ + boxes?: boolean; + /** Limit the depth of the snapshot tree */ + depth?: number; + /** Save snapshot to markdown file instead of returning it in the response. */ + filename?: string; + /** Exact target element reference from the page snapshot, or a unique element selector */ + target?: string; + }): Promise; + /** Perform click on a web page */ + browser_click(params: { + /** Button to click, defaults to left */ + button?: "left" | "right" | "middle"; + /** Whether to perform a double click instead of a single click */ + doubleClick?: boolean; + /** Human-readable element description used to obtain permission to interact with the element */ + element?: string; + /** Modifier keys to press */ + modifiers?: ("Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift")[]; + /** Exact target element reference from the page snapshot, or a unique element selector */ + target: string; + }): Promise; + /** Perform drag and drop between two elements */ + browser_drag(params: { + /** Human-readable target element description used to obtain the permission to interact with the element */ + endElement?: string; + /** Exact target element reference from the page snapshot, or a unique element selector */ + endTarget: string; + /** Human-readable source element description used to obtain the permission to interact with the element */ + startElement?: string; + /** Exact target element reference from the page snapshot, or a unique element selector */ + startTarget: string; + }): Promise; + /** Hover over element on page */ + browser_hover(params: { + /** Human-readable element description used to obtain permission to interact with the element */ + element?: string; + /** Exact target element reference from the page snapshot, or a unique element selector */ + target: string; + }): Promise; + /** Select an option in a dropdown */ + browser_select_option(params: { + /** Human-readable element description used to obtain permission to interact with the element */ + element?: string; + /** Exact target element reference from the page snapshot, or a unique element selector */ + target: string; + /** Array of values to select in the dropdown. This can be a single value or multiple values. */ + values: string[]; + }): Promise; + /** List, create, close, or select a browser tab. */ + browser_tabs(params: { + /** Operation to perform */ + action: "list" | "new" | "close" | "select"; + /** Tab index, used for close/select. If omitted for close, current tab is closed. */ + index?: number; + /** URL to navigate to in the new tab, used for new. */ + url?: string; + }): Promise; + /** Wait for text to appear or disappear or a specified time to pass */ + browser_wait_for(params?: { + /** The text to wait for */ + text?: string; + /** The text to wait for to disappear */ + textGone?: string; + /** The time to wait in seconds */ + time?: number; + }): Promise; +}; diff --git a/src/mcp/testdata/playwright.tools.json b/src/mcp/testdata/playwright.tools.json new file mode 100644 index 00000000..f72c3fec --- /dev/null +++ b/src/mcp/testdata/playwright.tools.json @@ -0,0 +1,823 @@ +{ + "tools": [ + { + "name": "browser_close", + "description": "Close the page", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "annotations": { + "title": "Close browser", + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": true + } + }, + { + "name": "browser_resize", + "description": "Resize the browser window", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "width": { + "type": "number", + "description": "Width of the browser window" + }, + "height": { + "type": "number", + "description": "Height of the browser window" + } + }, + "required": [ + "width", + "height" + ], + "additionalProperties": false + }, + "annotations": { + "title": "Resize browser window", + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": true + } + }, + { + "name": "browser_console_messages", + "description": "Returns all console messages", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "level": { + "default": "info", + "description": "Level of the console messages to return. Each level includes the messages of more severe levels. Defaults to \"info\".", + "type": "string", + "enum": [ + "error", + "warning", + "info", + "debug" + ] + }, + "all": { + "description": "Return all console messages since the beginning of the session, not just since the last navigation. Defaults to false.", + "type": "boolean" + }, + "filename": { + "description": "Filename to save the console messages to. If not provided, messages are returned as text.", + "type": "string" + } + }, + "required": [ + "level" + ], + "additionalProperties": false + }, + "annotations": { + "title": "Get console messages", + "readOnlyHint": true, + "destructiveHint": false, + "openWorldHint": true + } + }, + { + "name": "browser_handle_dialog", + "description": "Handle a dialog", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "accept": { + "type": "boolean", + "description": "Whether to accept the dialog." + }, + "promptText": { + "description": "The text of the prompt in case of a prompt dialog.", + "type": "string" + } + }, + "required": [ + "accept" + ], + "additionalProperties": false + }, + "annotations": { + "title": "Handle a dialog", + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": true + } + }, + { + "name": "browser_evaluate", + "description": "Evaluate JavaScript expression on page or element", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "element": { + "description": "Human-readable element description used to obtain permission to interact with the element", + "type": "string" + }, + "target": { + "description": "Exact target element reference from the page snapshot, or a unique element selector", + "type": "string" + }, + "function": { + "type": "string", + "description": "() => { /* code */ } or (element) => { /* code */ } when element is provided" + }, + "filename": { + "description": "Filename to save the result to. If not provided, result is returned as text.", + "type": "string" + } + }, + "required": [ + "function" + ], + "additionalProperties": false + }, + "annotations": { + "title": "Evaluate JavaScript", + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": true + } + }, + { + "name": "browser_file_upload", + "description": "Upload one or multiple files", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "paths": { + "description": "The absolute paths to the files to upload. Can be single file or multiple files. If omitted, file chooser is cancelled.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "annotations": { + "title": "Upload files", + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": true + } + }, + { + "name": "browser_drop", + "description": "Drop files or MIME-typed data onto an element, as if dragged from outside the page. At least one of \"paths\" or \"data\" must be provided.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "element": { + "description": "Human-readable element description used to obtain permission to interact with the element", + "type": "string" + }, + "target": { + "type": "string", + "description": "Exact target element reference from the page snapshot, or a unique element selector" + }, + "paths": { + "description": "Absolute paths to files to drop onto the element.", + "type": "array", + "items": { + "type": "string" + } + }, + "data": { + "description": "Data to drop, as a map of MIME type to string value (e.g. {\"text/plain\": \"hello\", \"text/uri-list\": \"https://example.com\"}).", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "target" + ], + "additionalProperties": false + }, + "annotations": { + "title": "Drop files or data onto an element", + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": true + } + }, + { + "name": "browser_find", + "description": "Search the accessibility snapshot of the current page for text or a regular expression. Returns matching snapshot nodes with a few lines of surrounding context (like search snippets), each shown under its path from the root of the tree, which is cheaper than capturing the whole snapshot when you only need to locate an element and its ref.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "description": "Plain text to search for in the page snapshot (case-insensitive substring match). Provide either text or regex, not both.", + "type": "string" + }, + "regex": { + "description": "Regular expression to search for in the page snapshot. Matching is case-sensitive by default; wrap the pattern in slashes to add flags, e.g. \"/error/i\" for case-insensitive. Provide either text or regex, not both.", + "type": "string" + } + }, + "additionalProperties": false + }, + "annotations": { + "title": "Find in page snapshot", + "readOnlyHint": true, + "destructiveHint": false, + "openWorldHint": true + } + }, + { + "name": "browser_fill_form", + "description": "Fill multiple form fields", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "fields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "element": { + "description": "Human-readable element description used to obtain permission to interact with the element", + "type": "string" + }, + "target": { + "type": "string", + "description": "Exact target element reference from the page snapshot, or a unique element selector" + }, + "name": { + "type": "string", + "description": "Human-readable field name" + }, + "type": { + "type": "string", + "enum": [ + "textbox", + "checkbox", + "radio", + "combobox", + "slider" + ], + "description": "Type of the field" + }, + "value": { + "type": "string", + "description": "Value to fill in the field. If the field is a checkbox, the value should be `true` or `false`. If the field is a combobox, the value should be the text of the option." + } + }, + "required": [ + "target", + "name", + "type", + "value" + ], + "additionalProperties": false + }, + "description": "Fields to fill in" + } + }, + "required": [ + "fields" + ], + "additionalProperties": false + }, + "annotations": { + "title": "Fill form", + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": true + } + }, + { + "name": "browser_press_key", + "description": "Press a key on the keyboard", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Name of the key to press or a character to generate, such as `ArrowLeft` or `a`" + } + }, + "required": [ + "key" + ], + "additionalProperties": false + }, + "annotations": { + "title": "Press a key", + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": true + } + }, + { + "name": "browser_type", + "description": "Type text into editable element", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "element": { + "description": "Human-readable element description used to obtain permission to interact with the element", + "type": "string" + }, + "target": { + "type": "string", + "description": "Exact target element reference from the page snapshot, or a unique element selector" + }, + "text": { + "type": "string", + "description": "Text to type into the element" + }, + "submit": { + "description": "Whether to submit entered text (press Enter after)", + "type": "boolean" + }, + "slowly": { + "description": "Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.", + "type": "boolean" + } + }, + "required": [ + "target", + "text" + ], + "additionalProperties": false + }, + "annotations": { + "title": "Type text", + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": true + } + }, + { + "name": "browser_navigate", + "description": "Navigate to a URL", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "The URL to navigate to" + } + }, + "required": [ + "url" + ], + "additionalProperties": false + }, + "annotations": { + "title": "Navigate to a URL", + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": true + } + }, + { + "name": "browser_navigate_back", + "description": "Go back to the previous page in the history", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "annotations": { + "title": "Go back", + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": true + } + }, + { + "name": "browser_network_requests", + "description": "Returns a numbered list of network requests since loading the page. Use browser_network_request with the number to get full details.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "static": { + "default": false, + "description": "Whether to include successful static resources like images, fonts, scripts, etc. Defaults to false.", + "type": "boolean" + }, + "filter": { + "description": "Only return requests whose URL matches this regexp (e.g. \"/api/.*user\").", + "type": "string" + }, + "filename": { + "description": "Filename to save the network requests to. If not provided, requests are returned as text.", + "type": "string" + } + }, + "required": [ + "static" + ], + "additionalProperties": false + }, + "annotations": { + "title": "List network requests", + "readOnlyHint": true, + "destructiveHint": false, + "openWorldHint": true + } + }, + { + "name": "browser_network_request", + "description": "Returns full details (headers and body) of a single network request, or a single part if `part` is set. Use the number from browser_network_requests.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "index": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991, + "description": "1-based index of the request, as printed by browser_network_requests." + }, + "part": { + "description": "Return only this part of the request. Omit to return full details.", + "type": "string", + "enum": [ + "request-headers", + "request-body", + "response-headers", + "response-body" + ] + }, + "filename": { + "description": "Filename to save the result to. If not provided, output is returned as text.", + "type": "string" + } + }, + "required": [ + "index" + ], + "additionalProperties": false + }, + "annotations": { + "title": "Show network request details", + "readOnlyHint": true, + "destructiveHint": false, + "openWorldHint": true + } + }, + { + "name": "browser_run_code_unsafe", + "description": "Run a Playwright code snippet. Unsafe: executes arbitrary JavaScript in the Playwright server process and is RCE-equivalent.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "code": { + "description": "A JavaScript function containing Playwright code to execute. It will be invoked with a single argument, page, which you can use for any page interaction. For example: `async (page) => { await page.getByRole('button', { name: 'Submit' }).click(); return await page.title(); }`", + "type": "string" + }, + "filename": { + "description": "Load code from the specified file. If both code and filename are provided, code will be ignored.", + "type": "string" + } + }, + "additionalProperties": false + }, + "annotations": { + "title": "Run Playwright code (unsafe)", + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": true + } + }, + { + "name": "browser_take_screenshot", + "description": "Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "element": { + "description": "Human-readable element description used to obtain permission to interact with the element", + "type": "string" + }, + "target": { + "description": "Exact target element reference from the page snapshot, or a unique element selector", + "type": "string" + }, + "type": { + "default": "png", + "description": "Image format for the screenshot. Default is png.", + "type": "string", + "enum": [ + "png", + "jpeg" + ] + }, + "filename": { + "description": "File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified. Prefer relative file names to stay within the output directory.", + "type": "string" + }, + "fullPage": { + "description": "When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Cannot be used with element screenshots.", + "type": "boolean" + }, + "scale": { + "default": "css", + "description": "Image resolution scale. \"css\" produces a screenshot sized in CSS pixels (smaller, consistent across devices). \"device\" produces a high-resolution screenshot using device pixels (larger, accounts for the device pixel ratio). Default is css.", + "type": "string", + "enum": [ + "css", + "device" + ] + } + }, + "required": [ + "type", + "scale" + ], + "additionalProperties": false + }, + "annotations": { + "title": "Take a screenshot", + "readOnlyHint": true, + "destructiveHint": false, + "openWorldHint": true + } + }, + { + "name": "browser_snapshot", + "description": "Capture accessibility snapshot of the current page, this is better than screenshot", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "target": { + "description": "Exact target element reference from the page snapshot, or a unique element selector", + "type": "string" + }, + "filename": { + "description": "Save snapshot to markdown file instead of returning it in the response.", + "type": "string" + }, + "depth": { + "description": "Limit the depth of the snapshot tree", + "type": "number" + }, + "boxes": { + "description": "Include each element's bounding box as [box=x,y,width,height] in the snapshot. Coordinates are viewport-relative, in CSS pixels (Element.getBoundingClientRect)", + "type": "boolean" + } + }, + "additionalProperties": false + }, + "annotations": { + "title": "Page snapshot", + "readOnlyHint": true, + "destructiveHint": false, + "openWorldHint": true + } + }, + { + "name": "browser_click", + "description": "Perform click on a web page", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "element": { + "description": "Human-readable element description used to obtain permission to interact with the element", + "type": "string" + }, + "target": { + "type": "string", + "description": "Exact target element reference from the page snapshot, or a unique element selector" + }, + "doubleClick": { + "description": "Whether to perform a double click instead of a single click", + "type": "boolean" + }, + "button": { + "description": "Button to click, defaults to left", + "type": "string", + "enum": [ + "left", + "right", + "middle" + ] + }, + "modifiers": { + "description": "Modifier keys to press", + "type": "array", + "items": { + "type": "string", + "enum": [ + "Alt", + "Control", + "ControlOrMeta", + "Meta", + "Shift" + ] + } + } + }, + "required": [ + "target" + ], + "additionalProperties": false + }, + "annotations": { + "title": "Click", + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": true + } + }, + { + "name": "browser_drag", + "description": "Perform drag and drop between two elements", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "startElement": { + "description": "Human-readable source element description used to obtain the permission to interact with the element", + "type": "string" + }, + "startTarget": { + "type": "string", + "description": "Exact target element reference from the page snapshot, or a unique element selector" + }, + "endElement": { + "description": "Human-readable target element description used to obtain the permission to interact with the element", + "type": "string" + }, + "endTarget": { + "type": "string", + "description": "Exact target element reference from the page snapshot, or a unique element selector" + } + }, + "required": [ + "startTarget", + "endTarget" + ], + "additionalProperties": false + }, + "annotations": { + "title": "Drag mouse", + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": true + } + }, + { + "name": "browser_hover", + "description": "Hover over element on page", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "element": { + "description": "Human-readable element description used to obtain permission to interact with the element", + "type": "string" + }, + "target": { + "type": "string", + "description": "Exact target element reference from the page snapshot, or a unique element selector" + } + }, + "required": [ + "target" + ], + "additionalProperties": false + }, + "annotations": { + "title": "Hover mouse", + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": true + } + }, + { + "name": "browser_select_option", + "description": "Select an option in a dropdown", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "element": { + "description": "Human-readable element description used to obtain permission to interact with the element", + "type": "string" + }, + "target": { + "type": "string", + "description": "Exact target element reference from the page snapshot, or a unique element selector" + }, + "values": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of values to select in the dropdown. This can be a single value or multiple values." + } + }, + "required": [ + "target", + "values" + ], + "additionalProperties": false + }, + "annotations": { + "title": "Select option", + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": true + } + }, + { + "name": "browser_tabs", + "description": "List, create, close, or select a browser tab.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "list", + "new", + "close", + "select" + ], + "description": "Operation to perform" + }, + "index": { + "description": "Tab index, used for close/select. If omitted for close, current tab is closed.", + "type": "number" + }, + "url": { + "description": "URL to navigate to in the new tab, used for new.", + "type": "string" + } + }, + "required": [ + "action" + ], + "additionalProperties": false + }, + "annotations": { + "title": "Manage tabs", + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": true + } + }, + { + "name": "browser_wait_for", + "description": "Wait for text to appear or disappear or a specified time to pass", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "time": { + "description": "The time to wait in seconds", + "type": "number" + }, + "text": { + "description": "The text to wait for", + "type": "string" + }, + "textGone": { + "description": "The text to wait for to disappear", + "type": "string" + } + }, + "additionalProperties": false + }, + "annotations": { + "title": "Wait for", + "readOnlyHint": true, + "destructiveHint": false, + "openWorldHint": true + } + } + ] +} diff --git a/src/mcp/testdata/sequentialthinking.d.ts b/src/mcp/testdata/sequentialthinking.d.ts new file mode 100644 index 00000000..a8631085 --- /dev/null +++ b/src/mcp/testdata/sequentialthinking.d.ts @@ -0,0 +1,29 @@ +declare const sequentialthinking: { + /** A detailed tool for dynamic and reflective problem-solving through thoughts. This tool helps analyze problems through a flexible thinking process that can adapt and evolve. Each thought can build on, question, or revise previous insights as understanding deepens. When to use this tool: - Breaking down complex problems into steps - Planning and design with room for revision - Analysis that might need course correction - Problems where the full scope might not be clear initially - Problems that require a multi-step solution - Tasks that need to maintain context over multiple steps - Situations where irrelevant information needs to be filtered out Key features: - You can adjust total_thoughts up or down as you progress - You can question or revise previous thoughts - You can add more thoughts even after reaching what seemed like the end - You can express uncertainty and explore alternative approaches - Not every thought needs to build linearly - you can branch or backtrack - Generates a solution hypothesis - Verifies the hypothesis based on the Chain of Thought steps - Repeats the process until satisfied - Provides a correct answer Parameters explained: - thought: Your current thinking step, which can include: * Regular analytical steps * Revisions of previous thoughts * Questions about previous decisions * Realizations about needing more analysis * Changes in approach * Hypothesis generation * Hypothesis verification - nextThoughtNeeded: True if you need more thinking, even if at what seemed like the end - thoughtNumber: Current number in sequence (can go beyond initial total if needed) - totalThoughts: Current estimate of thoughts needed (can be adjusted up/down) - isRevision: A boolean indicating if this thought revises previous thinking - revisesThought: If is_revision is true, which thought number is being reconsidered - branchFromThought: If branching, which thought number is the branching point - branchId: Identifier for the current branch (if any) - needsMoreThoughts: If reaching end but realizing more thoughts needed You should: 1. Start with an initial estimate of needed thoughts, but be ready to adjust 2. Feel free to question or revise previous thoughts 3. Don't hesitate to add more thoughts if needed, even at the "end" 4. Express uncertainty when present 5. Mark thoughts that revise previous thinking or branch into new paths 6. Ignore information that is irrelevant to the current step 7. Generate a solution hypothesis when appropriate 8. Verify the hypothesis based on the Chain of Thought steps 9. Repeat the process until satisfied with the solution 10. Provide a single, ideally correct answer as the final output 11. Only set nextThoughtNeeded to false when truly done and a satisfactory answer is reached */ + sequentialthinking(params: { + /** Branching point thought number */ + branchFromThought?: number; + /** Branch identifier */ + branchId?: string; + /** Whether this revises previous thinking */ + isRevision?: boolean; + /** If more thoughts are needed */ + needsMoreThoughts?: boolean; + /** Whether another thought step is needed */ + nextThoughtNeeded?: boolean; + /** Which thought is being reconsidered */ + revisesThought?: number; + /** Your current thinking step */ + thought: string; + /** Current thought number (numeric value, e.g., 1, 2, 3) */ + thoughtNumber: number; + /** Estimated total thoughts needed (numeric value, e.g., 5, 10) */ + totalThoughts: number; + }): Promise<{ + branches: string[]; + nextThoughtNeeded: boolean; + thoughtHistoryLength: number; + thoughtNumber: number; + totalThoughts: number; + }>; +}; diff --git a/src/mcp/testdata/sequentialthinking.tools.json b/src/mcp/testdata/sequentialthinking.tools.json new file mode 100644 index 00000000..eeb16da2 --- /dev/null +++ b/src/mcp/testdata/sequentialthinking.tools.json @@ -0,0 +1,105 @@ +{ + "tools": [ + { + "name": "sequentialthinking", + "title": "Sequential Thinking", + "description": "A detailed tool for dynamic and reflective problem-solving through thoughts.\nThis tool helps analyze problems through a flexible thinking process that can adapt and evolve.\nEach thought can build on, question, or revise previous insights as understanding deepens.\n\nWhen to use this tool:\n- Breaking down complex problems into steps\n- Planning and design with room for revision\n- Analysis that might need course correction\n- Problems where the full scope might not be clear initially\n- Problems that require a multi-step solution\n- Tasks that need to maintain context over multiple steps\n- Situations where irrelevant information needs to be filtered out\n\nKey features:\n- You can adjust total_thoughts up or down as you progress\n- You can question or revise previous thoughts\n- You can add more thoughts even after reaching what seemed like the end\n- You can express uncertainty and explore alternative approaches\n- Not every thought needs to build linearly - you can branch or backtrack\n- Generates a solution hypothesis\n- Verifies the hypothesis based on the Chain of Thought steps\n- Repeats the process until satisfied\n- Provides a correct answer\n\nParameters explained:\n- thought: Your current thinking step, which can include:\n * Regular analytical steps\n * Revisions of previous thoughts\n * Questions about previous decisions\n * Realizations about needing more analysis\n * Changes in approach\n * Hypothesis generation\n * Hypothesis verification\n- nextThoughtNeeded: True if you need more thinking, even if at what seemed like the end\n- thoughtNumber: Current number in sequence (can go beyond initial total if needed)\n- totalThoughts: Current estimate of thoughts needed (can be adjusted up/down)\n- isRevision: A boolean indicating if this thought revises previous thinking\n- revisesThought: If is_revision is true, which thought number is being reconsidered\n- branchFromThought: If branching, which thought number is the branching point\n- branchId: Identifier for the current branch (if any)\n- needsMoreThoughts: If reaching end but realizing more thoughts needed\n\nYou should:\n1. Start with an initial estimate of needed thoughts, but be ready to adjust\n2. Feel free to question or revise previous thoughts\n3. Don't hesitate to add more thoughts if needed, even at the \"end\"\n4. Express uncertainty when present\n5. Mark thoughts that revise previous thinking or branch into new paths\n6. Ignore information that is irrelevant to the current step\n7. Generate a solution hypothesis when appropriate\n8. Verify the hypothesis based on the Chain of Thought steps\n9. Repeat the process until satisfied with the solution\n10. Provide a single, ideally correct answer as the final output\n11. Only set nextThoughtNeeded to false when truly done and a satisfactory answer is reached", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "thought": { + "type": "string", + "description": "Your current thinking step" + }, + "nextThoughtNeeded": { + "description": "Whether another thought step is needed", + "type": "boolean" + }, + "thoughtNumber": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991, + "description": "Current thought number (numeric value, e.g., 1, 2, 3)" + }, + "totalThoughts": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991, + "description": "Estimated total thoughts needed (numeric value, e.g., 5, 10)" + }, + "isRevision": { + "description": "Whether this revises previous thinking", + "type": "boolean" + }, + "revisesThought": { + "description": "Which thought is being reconsidered", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "branchFromThought": { + "description": "Branching point thought number", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "branchId": { + "description": "Branch identifier", + "type": "string" + }, + "needsMoreThoughts": { + "description": "If more thoughts are needed", + "type": "boolean" + } + }, + "required": [ + "thought", + "thoughtNumber", + "totalThoughts" + ] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "thoughtNumber": { + "type": "number" + }, + "totalThoughts": { + "type": "number" + }, + "nextThoughtNeeded": { + "type": "boolean" + }, + "branches": { + "type": "array", + "items": { + "type": "string" + } + }, + "thoughtHistoryLength": { + "type": "number" + } + }, + "required": [ + "thoughtNumber", + "totalThoughts", + "nextThoughtNeeded", + "branches", + "thoughtHistoryLength" + ], + "additionalProperties": false + } + } + ] +} diff --git a/src/mcp/validate.rs b/src/mcp/validate.rs new file mode 100644 index 00000000..00131948 --- /dev/null +++ b/src/mcp/validate.rs @@ -0,0 +1,169 @@ +//! Checking a tool's result against the shape it declared. +//! +//! A tool's `outputSchema` becomes its TypeScript return type, but nothing in +//! MCP obliges a server to honor it, and answering with plain text is ordinary +//! rather than a defect. So the type is intent, not guarantee. +//! +//! **A mismatch never fails the call.** `"count: 3"` is not the declared object +//! but plainly carries the answer, and refusing it would throw that away. The +//! value passes through untouched and the model is told the shape did not hold — +//! the one thing it cannot work out for itself. Told nothing, it meets the +//! breach as an `undefined` property, blames its own code, and usually spends +//! another round trip probing the tool. +//! +//! **The notice is a tag, not prose.** A correction costing more context than +//! the type it corrects would defeat the design. Which field was wrong is left +//! out: the value travels with the tag, so the model can read what it got, and +//! the failure list goes to the log for whoever is debugging the server. That +//! is also why this is one validity check rather than an enumeration needing a +//! size cap. +//! +//! Two limits worth knowing: +//! +//! * **Remote `$ref`s are never resolved**, since fetching a URL a third-party +//! schema names would turn a declaration into an outbound request. The +//! dependency is built without its HTTP resolver, so such a reference simply +//! makes the schema uncompilable. +//! * **An uncompilable schema is not checked at all**, matching the renderer's +//! rule that generation never fails whatever a server sends. Nothing +//! meaningful was declared, so nothing is owed. +//! +//! Schemas are compiled per call rather than cached: compilation is microseconds +//! against schemas this size, the call it guards just paid a subprocess round +//! trip, and a cache would need invalidating whenever a restarted server +//! re-advertised its tools. + +use serde_json::Value; + +/// Check `value` against `schema`, returning a tag for the model if it does not +/// conform. +/// +/// `None` covers both "conforms" and "cannot be checked". Callers cannot +/// distinguish them, deliberately: neither is something to tell the model. +pub fn check_result(server: &str, tool: &str, schema: &Value, value: &Value) -> Option { + let validator = match jsonschema::validator_for(schema) { + Ok(validator) => validator, + Err(e) => { + tracing::debug!( + server, + tool, + error = %e, + "output schema could not be compiled; result not checked" + ); + return None; + } + }; + + if validator.is_valid(value) { + return None; + } + + // Detail the tag deliberately omits, for whoever debugs the server. + tracing::debug!( + server, + tool, + failures = %validator + .iter_errors(value) + .map(|e| e.to_string()) + .collect::>() + .join("; "), + "result did not match the declared output schema" + ); + + Some(format!( + "{server}.{tool}: result off-shape, treat as unknown" + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn schema() -> Value { + json!({ + "type": "object", + "properties": {"count": {"type": "number"}}, + "required": ["count"], + }) + } + + #[test] + fn a_conforming_value_is_not_remarked_on() { + assert_eq!( + check_result("s", "t", &schema(), &json!({"count": 1})), + None + ); + } + + #[test] + fn a_mismatch_names_the_tool_and_says_what_to_do() { + let tag = check_result("memory", "search", &schema(), &json!({"count": "one"})) + .expect("a string is not a number"); + assert_eq!(tag, "memory.search: result off-shape, treat as unknown"); + } + + #[test] + fn a_missing_required_field_is_a_mismatch() { + assert!( + check_result("s", "t", &schema(), &json!({})).is_some(), + "the required field is absent" + ); + } + + /// The case that must never fail a call: the server answered with text, so + /// the unwrap ladder handed back a string where an object was declared. + /// Ordinary MCP, not a defect, so the value stands and the model is tagged. + #[test] + fn an_unstructured_answer_is_tagged_not_rejected() { + let tag = check_result("s", "t", &schema(), &json!("count: 3")) + .expect("a string is not the declared object"); + assert!(tag.contains("treat as unknown"), "got: {tag}"); + } + + /// Servers commonly put JSON in a text block. The unwrap ladder parses it, + /// so it arrives as structured data and satisfies the schema. + #[test] + fn json_delivered_as_text_still_conforms() { + let parsed: Value = serde_json::from_str(r#"{"count": 2}"#).unwrap(); + assert_eq!(check_result("s", "t", &schema(), &parsed), None); + } + + /// The renderer never fails on a schema it cannot understand, so neither + /// does the checker. Nothing meaningful was declared, so nothing is owed. + #[test] + fn an_uncompilable_schema_is_not_checked() { + let bogus = json!({"$ref": "https://example.invalid/nope.json"}); + assert_eq!( + check_result("s", "t", &bogus, &json!("anything")), + None, + "an unresolvable reference must not produce a tag" + ); + } + + #[test] + fn a_permissive_schema_accepts_anything() { + let open = json!({"type": "object"}); + assert_eq!( + check_result("s", "t", &open, &json!({"whatever": true})), + None + ); + } + + /// However wrong the value is, the tag is one short line. A result whose + /// every element fails would otherwise crowd out the result it accompanies. + #[test] + fn the_tag_is_the_same_size_however_many_failures() { + let strict = json!({"type": "array", "items": {"type": "number"}}); + let one_bad: Value = json!(["x"]); + let all_bad: Value = (0..50).map(|_| json!("x")).collect(); + + let first = check_result("s", "t", &strict, &one_bad).expect("one item is wrong"); + let second = check_result("s", "t", &strict, &all_bad).expect("every item is wrong"); + assert_eq!( + first, second, + "the tag must not grow with the failure count" + ); + assert!(first.len() < 80, "tag should stay small: {}", first.len()); + } +} diff --git a/src/plugins.rs b/src/plugins.rs index efea5ea2..d1ef861a 100644 --- a/src/plugins.rs +++ b/src/plugins.rs @@ -11,28 +11,203 @@ use crate::pm::{ANY_VERSION, PackageId}; use symposium_install::Source; use sacp::schema::McpServer; +use std::collections::BTreeMap; /// An MCP server entry in a plugin manifest. -pub type McpServerEntry = McpServer; - -/// An MCP server entry with optional activation predicates. /// -/// The server's `depends-on` and `predicates` fields are merged into one -/// [`PredicateSet`](crate::predicate::PredicateSet); the server is only -/// registered when that set holds (ANDed with the plugin-level set). +/// Deliberately our own type rather than ACP's `McpServer`. That is a wire +/// type for ACP session setup, and using it as a manifest schema forced +/// authors to write `args = []` and `env = []` on every entry — neither field +/// carries a serde default, and because the type was flattened, omitting one +/// surfaced as `data did not match any variant of untagged enum McpServer` +/// rather than naming the field. No MCP client in the ecosystem requires +/// either. #[derive(Debug, Clone, Serialize)] pub struct PluginMcpServer { + /// Name the server is known by, both to a script and in agent config. + pub name: String, + #[serde( default, skip_serializing_if = "crate::predicate::PredicateSet::is_empty" )] pub predicates: crate::predicate::PredicateSet, + + /// Plugin-authored timing and tool-visibility overrides for this server. #[serde(flatten)] - pub server: McpServerEntry, + pub overrides: McpServerOverrides, + + pub transport: McpTransport, + + /// Installations to acquire before this server is first started. + /// + /// The way a package-runner server pre-fetches: an installation carrying + /// only `install_commands` warms a cache without the download landing on + /// the first tool call. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub requirements: Vec, +} + +/// How to reach a server. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum McpTransport { + Stdio(StdioServer), + Http(RemoteServer), + Sse(RemoteServer), +} + +/// A server run as a child process. +#[derive(Debug, Clone, Serialize)] +pub struct StdioServer { + pub command: StdioCommand, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub args: Vec, + /// Environment for the child. + /// + /// A map, matching every MCP client's config format — and the form we have + /// to write into agent configuration anyway. Ordered so written output is + /// deterministic. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub env: BTreeMap, + /// Directory to run in, resolved against the workspace root. + #[serde(skip_serializing_if = "Option::is_none")] + pub cwd: Option, +} + +/// Where a stdio server's executable comes from. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum StdioCommand { + /// A command resolved through `PATH`, or an absolute path. + Path(PathBuf), + /// The name of an `[[installations]]` entry, acquired before first use. + Installation(String), +} + +/// A server reached over HTTP. +#[derive(Debug, Clone, Serialize)] +pub struct RemoteServer { + pub url: String, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub headers: BTreeMap, +} + +impl PluginMcpServer { + /// The entry as ACP models it, for writing into agent configuration. + /// + /// One direction only: ACP's shape is an output format here, not our + /// source of truth. + pub fn to_acp_entry(&self) -> McpServer { + match &self.transport { + McpTransport::Stdio(stdio) => McpServer::Stdio( + sacp::schema::McpServerStdio::new( + self.name.clone(), + match &stdio.command { + StdioCommand::Path(path) => path.clone(), + // An unacquired installation has no path yet; the name + // is the best available placeholder. + StdioCommand::Installation(name) => PathBuf::from(name), + }, + ) + .args(stdio.args.clone()) + .env( + stdio + .env + .iter() + .map(|(name, value)| sacp::schema::EnvVariable::new(name, value)) + .collect(), + ), + ), + McpTransport::Http(remote) => McpServer::Http( + sacp::schema::McpServerHttp::new(self.name.clone(), remote.url.clone()) + .headers(header_list(&remote.headers)), + ), + McpTransport::Sse(remote) => McpServer::Sse( + sacp::schema::McpServerSse::new(self.name.clone(), remote.url.clone()) + .headers(header_list(&remote.headers)), + ), + } + } } +fn header_list(headers: &BTreeMap) -> Vec { + headers + .iter() + .map(|(name, value)| sacp::schema::HttpHeader::new(name, value)) + .collect() +} + +/// Per-server settings a plugin author may set, overriding the user's `[mcp]` +/// defaults. +/// +/// These describe the *server* — how slow it is to start, which of its tools +/// are worth exposing — which is the plugin author's knowledge. Sandbox limits +/// stay user-owned: a plugin must not be able to raise its own ceiling. +/// +/// The timeouts are clamped against the user's `script-timeout-secs` when a +/// server is dispatched, not here, because a manifest cannot see user config +/// and must not fail to parse because a user lowered their own limit. +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] +pub struct McpServerOverrides { + /// Ceiling on spawning this server and completing its handshake. + #[serde( + default, + rename = "startup-timeout-secs", + skip_serializing_if = "Option::is_none" + )] + pub startup_timeout_secs: Option, + + /// Ceiling on a single tool call to this server. + #[serde( + default, + rename = "tool-call-timeout-secs", + skip_serializing_if = "Option::is_none" + )] + pub tool_call_timeout_secs: Option, + + /// Expose only these tools. Mutually exclusive with `disabled-tools`. + /// + /// An empty list is distinct from absence: it exposes nothing. + #[serde( + default, + rename = "enabled-tools", + skip_serializing_if = "Option::is_none" + )] + pub enabled_tools: Option>, + + /// Expose everything except these tools. Mutually exclusive with + /// `enabled-tools`. + #[serde( + default, + rename = "disabled-tools", + skip_serializing_if = "Option::is_none" + )] + pub disabled_tools: Option>, +} + +impl McpServerOverrides { + fn validate(&self, server_name: &str) -> Result<()> { + if self.enabled_tools.is_some() && self.disabled_tools.is_some() { + bail!( + "mcp server `{server_name}` sets both `enabled-tools` and `disabled-tools`; \ + use one or the other" + ); + } + Ok(()) + } +} + +/// The manifest form: what a plugin author writes. +/// +/// `args`, `env`, `headers` and `cwd` all default, so the minimum entry is a +/// name and a command. `deny_unknown_fields` means a typo names the key +/// instead of being swallowed. #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct RawPluginMcpServer { + name: String, + #[serde(default, rename = "depends-on")] depends_on: Option, /// Rejected: renamed to `depends-on`. @@ -40,16 +215,113 @@ struct RawPluginMcpServer { crates: Option, #[serde(default)] predicates: crate::predicate::PredicateSet, - #[serde(flatten)] - server: McpServerEntry, + + #[serde(default, rename = "startup-timeout-secs")] + startup_timeout_secs: Option, + #[serde(default, rename = "tool-call-timeout-secs")] + tool_call_timeout_secs: Option, + #[serde(default, rename = "enabled-tools")] + enabled_tools: Option>, + #[serde(default, rename = "disabled-tools")] + disabled_tools: Option>, + + /// Executable to run, resolved through `PATH` unless absolute. + #[serde(default)] + command: Option, + /// An `[[installations]]` entry providing the executable. Acquired before + /// the server is first started. + #[serde(default)] + installation: Option, + #[serde(default)] + args: Vec, + #[serde(default)] + env: BTreeMap, + #[serde(default)] + cwd: Option, + + /// Reached over HTTP instead of as a child process. + #[serde(default)] + url: Option, + /// `http` (the default for a `url`) or `sse`. + #[serde(default)] + transport: Option, + #[serde(default)] + headers: BTreeMap, + + /// Installations to acquire before first use. + #[serde(default)] + requirements: Vec, } impl RawPluginMcpServer { fn validate(self) -> Result { reject_crates_field(&self.crates)?; + let name = self.name; + + let overrides = McpServerOverrides { + startup_timeout_secs: self.startup_timeout_secs, + tool_call_timeout_secs: self.tool_call_timeout_secs, + enabled_tools: self.enabled_tools, + disabled_tools: self.disabled_tools, + }; + overrides.validate(&name)?; + + let is_remote = self.url.is_some(); + let is_local = self.command.is_some() || self.installation.is_some(); + if is_remote && is_local { + bail!( + "mcp server `{name}` sets both `url` and a local command; \ + a server is reached one way or the other" + ); + } + + let transport = if let Some(url) = self.url { + if !self.args.is_empty() || !self.env.is_empty() || self.cwd.is_some() { + bail!( + "mcp server `{name}` sets `args`, `env` or `cwd` alongside `url`; \ + those apply only to a server run as a child process" + ); + } + let remote = RemoteServer { + url, + headers: self.headers, + }; + match self.transport.as_deref() { + None | Some("http") => McpTransport::Http(remote), + Some("sse") => McpTransport::Sse(remote), + Some(other) => bail!( + "mcp server `{name}` has unknown transport `{other}`; expected `http` or `sse`" + ), + } + } else { + if !self.headers.is_empty() { + bail!("mcp server `{name}` sets `headers` without a `url`"); + } + let command = match (self.command, self.installation) { + (Some(_), Some(_)) => bail!( + "mcp server `{name}` sets both `command` and `installation`; \ + use one or the other" + ), + (Some(path), None) => StdioCommand::Path(path), + (None, Some(installation)) => StdioCommand::Installation(installation), + (None, None) => { + bail!("mcp server `{name}` needs a `command`, an `installation`, or a `url`") + } + }; + McpTransport::Stdio(StdioServer { + command, + args: self.args, + env: self.env, + cwd: self.cwd, + }) + }; + Ok(PluginMcpServer { + name, predicates: crate::predicate::PredicateSet::merged(self.depends_on, self.predicates), - server: self.server, + overrides, + transport, + requirements: self.requirements, }) } } @@ -538,11 +810,24 @@ impl Plugin { pub fn applicable_mcp_servers( &self, ctx: &mut crate::predicate::PredicateContext, - ) -> Vec { + ) -> Vec { + self.applicable_mcp_entries(ctx) + .into_iter() + .map(PluginMcpServer::to_acp_entry) + .collect() + } + + /// Applicable MCP servers with their per-server overrides intact. + /// + /// Registration only needs the transport details, but running a server + /// needs the timings and tool filters its plugin declared. + pub fn applicable_mcp_entries( + &self, + ctx: &mut crate::predicate::PredicateContext, + ) -> Vec<&PluginMcpServer> { self.mcp_servers .iter() .filter(|s| s.predicates.evaluate(ctx)) - .map(|s| s.server.clone()) .collect() } } @@ -2683,7 +2968,6 @@ mod tests { name = "server" command = "/usr/bin/true" args = ["--stdio"] - env = [] crates = ["serde"] "#}, ] { @@ -3481,6 +3765,42 @@ mod tests { assert!(warnings[0].contains("dormant"), "{warnings:?}"); } + /// A failed manifest has to say what was wrong with it. The outermost + /// context names the file; the cause is what an author can act on. + #[test] + fn manifest_load_errors_carry_their_cause() { + use crate::test_utils::{File, instantiate_fixture}; + let tmp = instantiate_fixture(&[File( + "broken/SYMPOSIUM.toml", + indoc! {r#" + name = "broken" + depends-on = ["*"] + + [[mcp_servers]] + name = "s" + command = "x" + startup_timeout_secs = 30 + "#}, + )]); + + let contents = scan_source_dir(tmp.path(), "test").expect("scan"); + let err = contents + .plugins + .into_iter() + .find_map(Result::err) + .expect("the manifest should have failed"); + + let chained = format!("{err:#}"); + assert!( + chained.contains("SYMPOSIUM.toml"), + "should name the file, got: {chained}" + ); + assert!( + chained.contains("startup_timeout_secs"), + "should name the offending key, got: {chained}" + ); + } + #[test] fn parse_manifest_with_no_mcp_servers() { let plugin = from_str(SAMPLE).expect("parse"); @@ -3488,69 +3808,290 @@ mod tests { } #[test] - fn mcp_entry_stdio() { - let entry: McpServerEntry = toml::from_str(indoc! {r#" - name = "my-server" - command = "/usr/local/bin/my-server" + fn mcp_server_without_overrides_leaves_them_unset() { + let plugin = from_str(indoc! {r#" + name = "p" + depends-on = ["*"] + + [[mcp_servers]] + name = "sqlx" + command = "/usr/bin/true" + "#}) + .expect("parse"); + assert_eq!( + plugin.mcp_servers[0].overrides, + McpServerOverrides::default(), + "absent overrides should stay None, got: {:#?}", + plugin.mcp_servers[0].overrides + ); + } + + #[test] + fn mcp_server_parses_timing_and_tool_overrides() { + let plugin = from_str(indoc! {r#" + name = "p" + depends-on = ["*"] + + [[mcp_servers]] + name = "sqlx" + command = "/usr/bin/true" + startup-timeout-secs = 45 + tool-call-timeout-secs = 90 + enabled-tools = ["query", "explain"] + "#}) + .expect("parse"); + let o = &plugin.mcp_servers[0].overrides; + assert_eq!(o.startup_timeout_secs, Some(45)); + assert_eq!(o.tool_call_timeout_secs, Some(90)); + assert_eq!( + o.enabled_tools.as_deref(), + Some(&["query".to_string(), "explain".to_string()][..]) + ); + assert_eq!(o.disabled_tools, None); + } + + /// An empty allow-list means "expose nothing", which is distinct from + /// omitting the field. + #[test] + fn mcp_server_empty_enabled_tools_is_not_absence() { + let plugin = from_str(indoc! {r#" + name = "p" + depends-on = ["*"] + + [[mcp_servers]] + name = "sqlx" + command = "/usr/bin/true" + enabled-tools = [] + "#}) + .expect("parse"); + assert_eq!(plugin.mcp_servers[0].overrides.enabled_tools, Some(vec![])); + } + + #[test] + fn mcp_server_with_both_tool_lists_errors() { + let err = from_str(indoc! {r#" + name = "p" + depends-on = ["*"] + + [[mcp_servers]] + name = "sqlx" + command = "/usr/bin/true" + enabled-tools = ["query"] + disabled-tools = ["drop"] + "#}) + .expect_err("both tool lists should be rejected"); + assert!( + err.to_string().contains("sqlx"), + "error should name the offending server, got: {err}" + ); + } + + #[test] + fn mcp_server_overrides_work_on_http_transport() { + let plugin = from_str(indoc! {r#" + name = "p" + depends-on = ["*"] + + [[mcp_servers]] + name = "remote" + url = "http://localhost:8080/mcp" + tool-call-timeout-secs = 15 + "#}) + .expect("parse"); + assert_eq!( + plugin.mcp_servers[0].overrides.tool_call_timeout_secs, + Some(15) + ); + } + + fn mcp_entry(toml_src: &str) -> Result { + toml::from_str::(toml_src)?.validate() + } + + /// The minimum an author should have to write. Previously this needed + /// `args = []` and `env = []` as well, because the manifest flattened a + /// wire type whose fields carry no serde defaults. + #[test] + fn mcp_entry_needs_only_a_name_and_command() { + let entry = mcp_entry(indoc! {r#" + name = "sqlx" + command = "sqlx-mcp" + "#}) + .expect("parse"); + + assert_eq!(entry.name, "sqlx"); + let McpTransport::Stdio(stdio) = &entry.transport else { + panic!("expected stdio, got {:#?}", entry.transport); + }; + assert_eq!(stdio.command, StdioCommand::Path("sqlx-mcp".into())); + assert!(stdio.args.is_empty()); + assert!(stdio.env.is_empty()); + assert_eq!(stdio.cwd, None); + } + + /// A map, matching every MCP client's config format, and written as an + /// inline table so it stays visibly per-server. + #[test] + fn mcp_entry_reads_env_as_a_map() { + let entry = mcp_entry(indoc! {r#" + name = "sqlx" + command = "sqlx-mcp" args = ["--stdio"] - env = [] + env = { RUST_LOG = "debug", API_KEY = "x" } + cwd = "crates/db" "#}) .expect("parse"); - expect_test::expect![[r#" - Stdio( - McpServerStdio { - name: "my-server", - command: "/usr/local/bin/my-server", - args: [ - "--stdio", - ], - env: [], - meta: None, - }, - )"#]] - .assert_eq(&format!("{entry:#?}")); + + let McpTransport::Stdio(stdio) = &entry.transport else { + panic!("expected stdio"); + }; + assert_eq!(stdio.args, vec!["--stdio".to_string()]); + assert_eq!(stdio.env.get("RUST_LOG").map(String::as_str), Some("debug")); + assert_eq!(stdio.env.get("API_KEY").map(String::as_str), Some("x")); + assert_eq!(stdio.cwd, Some("crates/db".into())); } #[test] - fn mcp_entry_http() { - let entry: McpServerEntry = toml::from_str(indoc! {r#" - type = "http" - name = "my-server" + fn mcp_entry_accepts_an_installation_instead_of_a_command() { + let entry = mcp_entry(indoc! {r#" + name = "sqlx" + installation = "sqlx-mcp" + "#}) + .expect("parse"); + + let McpTransport::Stdio(stdio) = &entry.transport else { + panic!("expected stdio"); + }; + assert_eq!( + stdio.command, + StdioCommand::Installation("sqlx-mcp".to_string()) + ); + } + + #[test] + fn mcp_entry_rejects_both_command_and_installation() { + let err = mcp_entry(indoc! {r#" + name = "sqlx" + command = "sqlx-mcp" + installation = "sqlx-mcp" + "#}) + .expect_err("both should be rejected"); + assert!(err.to_string().contains("one or the other"), "got: {err}"); + } + + #[test] + fn mcp_entry_needs_some_way_to_reach_the_server() { + let err = mcp_entry(indoc! {r#" + name = "sqlx" + "#}) + .expect_err("no transport should be rejected"); + assert!(err.to_string().contains("needs a `command`"), "got: {err}"); + } + + /// A typo should name the key, not report a mismatch on a type the author + /// never mentioned. + #[test] + fn mcp_entry_rejects_an_unknown_key_by_name() { + let err = toml::from_str::(indoc! {r#" + name = "sqlx" + command = "sqlx-mcp" + startup_timeout_secs = 30 + "#}) + .expect_err("misspelled key should not parse"); + assert!( + err.to_string().contains("startup_timeout_secs"), + "got: {err}" + ); + } + + // -- remote transports -- + + #[test] + fn mcp_entry_defaults_a_url_to_http() { + let entry = mcp_entry(indoc! {r#" + name = "remote" url = "http://localhost:8080/mcp" - headers = [] + headers = { Authorization = "Bearer x" } "#}) .expect("parse"); - expect_test::expect![[r#" - Http( - McpServerHttp { - name: "my-server", - url: "http://localhost:8080/mcp", - headers: [], - meta: None, - }, - )"#]] - .assert_eq(&format!("{entry:#?}")); + + let McpTransport::Http(remote) = &entry.transport else { + panic!("expected http, got {:#?}", entry.transport); + }; + assert_eq!(remote.url, "http://localhost:8080/mcp"); + assert_eq!( + remote.headers.get("Authorization").map(String::as_str), + Some("Bearer x") + ); } #[test] - fn mcp_entry_sse() { - let entry: McpServerEntry = toml::from_str(indoc! {r#" - type = "sse" - name = "my-server" + fn mcp_entry_accepts_sse_transport() { + let entry = mcp_entry(indoc! {r#" + name = "remote" url = "http://localhost:8080/sse" - headers = [] + transport = "sse" "#}) .expect("parse"); + assert!(matches!(entry.transport, McpTransport::Sse(_))); + } + + #[test] + fn mcp_entry_rejects_child_process_fields_on_a_url() { + let err = mcp_entry(indoc! {r#" + name = "remote" + url = "http://localhost:8080/mcp" + args = ["--stdio"] + "#}) + .expect_err("args make no sense for a url"); + assert!(err.to_string().contains("child process"), "got: {err}"); + } + + #[test] + fn mcp_entry_rejects_a_url_and_a_command_together() { + let err = mcp_entry(indoc! {r#" + name = "remote" + url = "http://localhost:8080/mcp" + command = "thing" + "#}) + .expect_err("one way or the other"); + assert!( + err.to_string().contains("one way or the other"), + "got: {err}" + ); + } + + /// Agent configuration is still written in ACP's shape, so the + /// conversion out of our own type has to be exercised. + #[test] + fn mcp_entry_converts_to_the_acp_shape() { + let entry = mcp_entry(indoc! {r#" + name = "my-server" + command = "/usr/local/bin/my-server" + args = ["--stdio"] + env = { API_KEY = "x" } + "#}) + .expect("parse"); + expect_test::expect![[r#" - Sse( - McpServerSse { + Stdio( + McpServerStdio { name: "my-server", - url: "http://localhost:8080/sse", - headers: [], + command: "/usr/local/bin/my-server", + args: [ + "--stdio", + ], + env: [ + EnvVariable { + name: "API_KEY", + value: "x", + meta: None, + }, + ], meta: None, }, )"#]] - .assert_eq(&format!("{entry:#?}")); + .assert_eq(&format!("{:#?}", entry.to_acp_entry())); } /// Cargo-installed binary referenced by name as the hook's command. diff --git a/src/pm/cargo/workspace.rs b/src/pm/cargo/workspace.rs index 20cdc9a1..673da0a1 100644 --- a/src/pm/cargo/workspace.rs +++ b/src/pm/cargo/workspace.rs @@ -270,8 +270,11 @@ pub fn workspace_dir_name(workspace_root: &Path) -> String { format!("{tail}-{hash}") } -/// Get a file's mtime as seconds since the Unix epoch. +/// Get a file's mtime as nanoseconds since the Unix epoch. /// Returns `None` if the file doesn't exist or its metadata can't be read. +/// +/// Nanoseconds, not seconds: callers ask "did this change since I last +/// looked", and an edit followed by a check is well inside one second. pub fn file_mtime(path: &Path) -> Option { let meta = fs::metadata(path).ok()?; let mtime = meta.modified().ok()?; @@ -279,7 +282,8 @@ pub fn file_mtime(path: &Path) -> Option { mtime .duration_since(SystemTime::UNIX_EPOCH) .unwrap_or_default() - .as_secs(), + .as_nanos() + .min(u64::MAX as u128) as u64, ) } diff --git a/src/sync.rs b/src/sync.rs index 71d534c8..0c987d42 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -347,10 +347,10 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve } // Collect MCP servers from the same active plugin set. - let mut mcp_servers: Vec = Vec::new(); + let mut plugin_servers: Vec = Vec::new(); for p in &active { if p.applies(&mut ctx) { - mcp_servers.extend(p.plugin.applicable_mcp_servers(&mut ctx)); + plugin_servers.extend(p.plugin.applicable_mcp_servers(&mut ctx)); } } if let Err(e) = ctx.persist_disk_cache(&predicate_cache_path) { @@ -361,7 +361,7 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve ); } - let server_names: Vec<&str> = mcp_servers + let plugin_server_names: Vec<&str> = plugin_servers .iter() .map(|s| match s { sacp::schema::McpServer::Stdio(s) => s.name.as_str(), @@ -371,6 +371,28 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve }) .collect(); + // One entry, not one per plugin. The agent then loads two tool schemas + // instead of every plugin server's, and the workspace's own tools stay + // out of `.claude/` and its equivalents. + let mcp_servers = if sym.config.mcp.enabled { + vec![meta_server_entry()] + } else { + plugin_servers.clone() + }; + // Whichever set is not in use has to be removed, or entries written by a + // previous configuration linger in agent config forever. + let stale_names: Vec<&str> = if sym.config.mcp.enabled { + plugin_server_names.clone() + } else { + vec![META_SERVER_NAME] + }; + // Every name this sync could have written, for reaping dropped agents. + let server_names: Vec<&str> = plugin_server_names + .iter() + .copied() + .chain(std::iter::once(META_SERVER_NAME)) + .collect(); + // Sync each configured agent let agent_names: Vec = sym.config.agents.iter().map(|a| a.name.clone()).collect(); @@ -406,6 +428,7 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve agent .register_hooks(&hook_root, sym, out) .context("failed to register hooks")?; + let _ = agent.unregister_global_mcp_servers(&hook_root, &stale_names, out); agent .register_global_mcp_servers(&hook_root, &mcp_servers, out) .context("failed to register MCP servers")?; @@ -550,6 +573,21 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve Ok(()) } +/// Name of the single entry written into agent configuration. +pub const META_SERVER_NAME: &str = "symposium"; + +/// The meta-server as an agent configuration entry. +/// +/// Named by command rather than absolute path, matching how hooks are +/// registered: an absolute path breaks the moment the binary is reinstalled +/// somewhere else. +fn meta_server_entry() -> sacp::schema::McpServer { + sacp::schema::McpServer::Stdio( + sacp::schema::McpServerStdio::new(META_SERVER_NAME, "cargo-agents") + .args(vec!["mcp-serve".to_string()]), + ) +} + /// Register global hooks for all configured agents. /// Register hooks for all configured agents. Uses `home_dir` (global scope). /// Called from `init` after writing the user config. @@ -558,7 +596,7 @@ pub async fn register_hooks(sym: &Symposium, out: &Output) -> Result<()> { let mcp_servers: Vec = registry .plugins .iter() - .flat_map(|p| p.plugin.mcp_servers.iter().map(|s| s.server.clone())) + .flat_map(|p| p.plugin.mcp_servers.iter().map(|s| s.to_acp_entry())) .collect(); let server_names: Vec<&str> = mcp_servers diff --git a/tests/fixtures/crate-facets0/crate-f/SYMPOSIUM.toml b/tests/fixtures/crate-facets0/crate-f/SYMPOSIUM.toml index 834fc281..eceed103 100644 --- a/tests/fixtures/crate-facets0/crate-f/SYMPOSIUM.toml +++ b/tests/fixtures/crate-facets0/crate-f/SYMPOSIUM.toml @@ -10,7 +10,6 @@ name = "facet-server" depends-on = ["*"] command = "/usr/bin/true" args = ["--stdio"] -env = [] [[installations]] name = "facet-install" diff --git a/tests/fixtures/mcp-filtering0/dot-symposium/plugins/mcp-plugin/SYMPOSIUM.toml b/tests/fixtures/mcp-filtering0/dot-symposium/plugins/mcp-plugin/SYMPOSIUM.toml index 1217c3e8..a35952a8 100644 --- a/tests/fixtures/mcp-filtering0/dot-symposium/plugins/mcp-plugin/SYMPOSIUM.toml +++ b/tests/fixtures/mcp-filtering0/dot-symposium/plugins/mcp-plugin/SYMPOSIUM.toml @@ -6,24 +6,20 @@ name = "always-server" depends-on = ["*"] command = "/usr/bin/true" args = ["--stdio"] -env = [] [[mcp_servers]] name = "serde-server" depends-on = ["serde"] command = "/usr/bin/true" args = ["--stdio"] -env = [] [[mcp_servers]] name = "missing-crate-server" depends-on = ["reqwest"] command = "/usr/bin/true" args = ["--stdio"] -env = [] [[mcp_servers]] name = "inherited-server" command = "/usr/bin/true" args = ["--stdio"] -env = [] diff --git a/tests/init_sync.rs b/tests/init_sync.rs index 21576512..21c2bcb1 100644 --- a/tests/init_sync.rs +++ b/tests/init_sync.rs @@ -415,9 +415,11 @@ async fn add_agent_is_additive() { .unwrap(); } -/// `sync` filters MCP servers by their `depends-on` predicates. +/// One entry is written, not one per plugin: the agent loads two tool +/// schemas rather than every plugin server's, and the workspace's own tools +/// stay out of agent configuration. #[tokio::test] -async fn sync_filters_mcp_servers_by_crates() { +async fn sync_registers_only_the_meta_server() { with_fixture( TestMode::SimulationOnly, &["mcp-filtering0", "workspace0"], @@ -426,29 +428,54 @@ async fn sync_filters_mcp_servers_by_crates() { ctx.symposium(&["sync"]).await?; let workspace_root = ctx.workspace_root.as_ref().unwrap(); - let settings_path = workspace_root.join(".claude/settings.json"); - let settings = std::fs::read_to_string(&settings_path)?; + let settings: serde_json::Value = serde_json::from_str(&std::fs::read_to_string( + workspace_root.join(".claude/settings.json"), + )?)?; + let servers = settings["mcpServers"] + .as_object() + .expect("mcpServers should be an object"); - // always-server (depends-on = ["*"]) → registered - assert!( - settings.contains("always-server"), - "wildcard MCP server should be registered" - ); - // serde-server (depends-on = ["serde"]) → registered (serde is in workspace0) - assert!( - settings.contains("serde-server"), - "serde MCP server should be registered" - ); - // inherited-server (no crates, inherits from plugin) → registered - assert!( - settings.contains("inherited-server"), - "inherited MCP server should be registered" - ); - // missing-crate-server (depends-on = ["reqwest"]) → NOT registered + let names: Vec<&String> = servers.keys().collect(); + assert_eq!(names, vec!["symposium"], "got: {names:?}"); + assert_eq!(servers["symposium"]["args"][0], "mcp-serve"); + + Ok(()) + }, + ) + .await + .unwrap(); +} + +/// Turning the meta-server off restores per-plugin registration, still +/// filtered by `depends-on`. +#[tokio::test] +async fn sync_registers_plugin_servers_when_the_meta_server_is_disabled() { + with_fixture( + TestMode::SimulationOnly, + &["mcp-filtering0", "workspace0"], + async |mut ctx| { + let config = ctx.sym.config_dir().join("config.toml"); + let existing = std::fs::read_to_string(&config)?; + std::fs::write(&config, format!("{existing}\n[mcp]\nenabled = false\n"))?; + ctx.sym = symposium::config::Symposium::from_dir(ctx.sym.config_dir()); + + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["sync"]).await?; + + let workspace_root = ctx.workspace_root.as_ref().unwrap(); + let settings = std::fs::read_to_string(workspace_root.join(".claude/settings.json"))?; + + // depends-on = ["*"], and ["serde"] which workspace0 provides. + assert!(settings.contains("always-server"), "got: {settings}"); + assert!(settings.contains("serde-server"), "got: {settings}"); + // Inherits the plugin's own predicate. + assert!(settings.contains("inherited-server"), "got: {settings}"); + // depends-on = ["reqwest"], which the workspace does not have. assert!( !settings.contains("missing-crate-server"), - "reqwest MCP server should NOT be registered" + "got: {settings}" ); + assert!(!settings.contains("\"symposium\""), "got: {settings}"); Ok(()) }, @@ -709,9 +736,13 @@ async fn sync_installs_skill_via_crate_manifest() { .unwrap(); } -/// `sync` registers an MCP server declared by a crate reached through a -/// `[[plugins]]` chained reference — a crate-sourced plugin's MCP servers flow -/// through the active plugin set, not just its skills. +/// An MCP server declared by a crate reached through a `[[plugins]]` chained +/// reference is available to the agent — a crate-sourced plugin's MCP servers +/// flow through the active plugin set, not just its skills. +/// +/// With the meta-server on (the default) the agent's config carries only the +/// `symposium` entry, so what proves the server reached the agent is that +/// resolution finds it behind that entry. /// /// Fixture layout: /// - `facet-host` depends on `crate-f` (path dep) @@ -727,11 +758,20 @@ async fn sync_registers_mcp_server_from_chained_crate() { ctx.symposium(&["init", "--add-agent", "claude"]).await?; ctx.symposium(&["sync"]).await?; - let workspace_root = ctx.workspace_root.as_ref().unwrap(); + let workspace_root = ctx.workspace_root.as_ref().unwrap().clone(); let settings = std::fs::read_to_string(workspace_root.join(".claude/settings.json"))?; assert!( - settings.contains("facet-server"), - "chained crate's MCP server should be registered:\n{settings}" + settings.contains(symposium::sync::META_SERVER_NAME), + "the meta-server entry should be registered:\n{settings}" + ); + + let resolution = symposium::mcp::resolve::resolve(&ctx.sym, &workspace_root).await; + let names: Vec<&str> = resolution.servers.iter().map(|s| s.name.as_str()).collect(); + assert!( + names.contains(&"facet-server"), + "chained crate's MCP server should resolve, got {names:?} \ + (rejected: {:?})", + resolution.rejected ); Ok(()) }, diff --git a/tests/mcp_meta_server.rs b/tests/mcp_meta_server.rs new file mode 100644 index 00000000..69aaa785 --- /dev/null +++ b/tests/mcp_meta_server.rs @@ -0,0 +1,1021 @@ +//! `cargo agents mcp-serve` spoken to as a real MCP client over stdio. +//! +//! The unit tests cover what the server decides; these cover that a client +//! can actually talk to it — process spawn, handshake, framing, and the +//! stdout discipline the transport depends on. + +use std::path::PathBuf; +use std::process::Stdio; +use std::time::Duration; + +use rmcp::ServiceExt; +use rmcp::model::CallToolRequestParams; +use rmcp::transport::TokioChildProcess; + +fn binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_cargo-agents")) +} + +/// Point the binary at an empty config directory, so a developer's own +/// settings cannot change what a test sees. +fn isolated_home() -> tempfile::TempDir { + tempfile::tempdir().expect("temp dir") +} + +async fn connect(home: &tempfile::TempDir) -> rmcp::service::RunningService { + let mut command = tokio::process::Command::new(binary()); + command + .arg("mcp-serve") + .env("SYMPOSIUM_HOME", home.path()) + .stderr(Stdio::null()); + + let transport = TokioChildProcess::new(command).expect("spawn mcp-serve"); + tokio::time::timeout(Duration::from_secs(30), ().serve(transport)) + .await + .expect("handshake timed out") + .expect("handshake failed") +} + +/// The point of the design: an agent sees two tools, not every plugin +/// server's tools. +#[tokio::test(flavor = "multi_thread")] +async fn advertises_two_tools() { + let home = isolated_home(); + let client = connect(&home).await; + + let mut names: Vec = client + .list_all_tools() + .await + .expect("tools/list") + .into_iter() + .map(|t| t.name.to_string()) + .collect(); + names.sort(); + + assert_eq!(names, vec!["execute".to_string(), "list_tools".to_string()]); + let _ = client.cancel().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn reports_itself_as_symposium() { + let home = isolated_home(); + let client = connect(&home).await; + + let info = client.peer_info().expect("server info"); + let server_info = info.server_info.as_ref().expect("server implementation"); + assert_eq!(server_info.name, "symposium"); + assert!( + info.capabilities.tools.is_some(), + "tools capability must be advertised" + ); + let _ = client.cancel().await; +} + +/// A client filtering on read-only annotations must not mistake arbitrary +/// code execution for a safe operation. +#[tokio::test(flavor = "multi_thread")] +async fn execute_is_annotated_as_destructive() { + let home = isolated_home(); + let client = connect(&home).await; + + let tools = client.list_all_tools().await.expect("tools/list"); + let execute = tools + .iter() + .find(|t| t.name.as_ref() == "execute") + .expect("execute tool"); + let annotations = execute.annotations.as_ref().expect("annotations"); + + assert_eq!(annotations.read_only_hint, Some(false)); + assert_eq!(annotations.destructive_hint, Some(true)); + let _ = client.cancel().await; +} + +/// With nothing applicable, the tools still answer — and say so, rather than +/// failing in a way that reads as a broken connection. +#[tokio::test(flavor = "multi_thread")] +async fn list_tools_answers_when_nothing_applies() { + let home = isolated_home(); + let client = connect(&home).await; + + let result = client + .call_tool(CallToolRequestParams::new("list_tools")) + .await + .expect("list_tools should answer"); + + assert_ne!(result.is_error, Some(true), "answering is not an error"); + let _ = client.cancel().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn unknown_tool_names_the_available_ones() { + let home = isolated_home(); + let client = connect(&home).await; + + let err = client + .call_tool(CallToolRequestParams::new("nonexistent")) + .await + .expect_err("an unknown tool is a protocol error"); + let text = err.to_string(); + + assert!( + text.contains("list_tools") && text.contains("execute"), + "the error should name what is available, got: {text}" + ); + let _ = client.cancel().await; +} + +/// A workspace with one real backing MCP server behind a plugin manifest. +struct Workspace { + _dir: tempfile::TempDir, + home: PathBuf, + root: PathBuf, +} + +fn mock_binary() -> PathBuf { + let mut dir = binary(); + dir.pop(); + dir.join("examples").join("mock-mcp-server") +} + +fn workspace_with_backing_server() -> Workspace { + workspace_serving(serde_json::json!({ + "name": "sqlx", + "tools": [ + {"name": "query", "description": "Run a SQL query", + "inputSchema": {"type": "object", + "properties": {"sql": {"type": "string"}}, "required": ["sql"]}, + "behavior": {"kind": "echo"}}, + {"name": "migrate-status", "description": "Show migrations", + "behavior": {"kind": "text", "text": "up to date"}} + ] + })) +} + +fn workspace_serving(mock: serde_json::Value) -> Workspace { + workspace_serving_as("sqlx", mock) +} + +/// As [`workspace_serving`], with the manifest's server name spelled out. That +/// name, not the one the mock reports in its handshake, is the namespace a +/// script addresses. +fn workspace_serving_as(server: &str, mock: serde_json::Value) -> Workspace { + let dir = tempfile::tempdir().expect("temp dir"); + let base = dir.path().to_path_buf(); + let home = base.join("home"); + let root = base.join("ws"); + std::fs::create_dir_all(home.join("plugins/db")).unwrap(); + std::fs::create_dir_all(root.join("src")).unwrap(); + + let mock_config = base.join("mock.json"); + std::fs::write(&mock_config, mock.to_string()).unwrap(); + + std::fs::write( + home.join("config.toml"), + "hook-scope = \"project\"\n[defaults]\nsymposium-recommendations = false\n", + ) + .unwrap(); + std::fs::write( + home.join("plugins/db/SYMPOSIUM.toml"), + format!( + "name = \"db-plugin\"\ndepends-on = [\"*\"]\n\n\ + [[mcp_servers]]\nname = {:?}\ncommand = {:?}\n\ + args = [\"--config\", {:?}]\n", + server, + mock_binary().display().to_string(), + mock_config.display().to_string(), + ), + ) + .unwrap(); + std::fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"e2e\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\ + [dependencies]\nserde = \"1\"\n", + ) + .unwrap(); + std::fs::write(root.join("src/lib.rs"), "// lib\n").unwrap(); + + Workspace { + _dir: dir, + home, + root, + } +} + +async fn connect_in(workspace: &Workspace) -> rmcp::service::RunningService { + let mut command = tokio::process::Command::new(binary()); + command + .arg("mcp-serve") + .current_dir(&workspace.root) + .env("SYMPOSIUM_HOME", &workspace.home) + .stderr(Stdio::null()); + + let transport = TokioChildProcess::new(command).expect("spawn"); + tokio::time::timeout(Duration::from_secs(60), ().serve(transport)) + .await + .expect("handshake timed out") + .expect("handshake failed") +} + +fn text_of(result: &rmcp::model::CallToolResult) -> String { + result + .content + .iter() + .filter_map(|block| match block { + rmcp::model::ContentBlock::Text(t) => Some(t.text.clone()), + _ => None, + }) + .collect::>() + .join("\n") +} + +/// Tools of a workspace's backing servers, discovered through the plugin +/// registry and a live `tools/list`. +#[tokio::test(flavor = "multi_thread")] +async fn lists_tools_of_a_backing_server() { + let workspace = workspace_with_backing_server(); + let client = connect_in(&workspace).await; + + let result = client + .call_tool(CallToolRequestParams::new("list_tools")) + .await + .expect("list_tools"); + let text = text_of(&result); + + assert!(text.contains("sqlx:"), "got: {text}"); + assert!(text.contains("query"), "got: {text}"); + assert!(text.contains("migrate-status"), "got: {text}"); + let _ = client.cancel().await; +} + +/// Naming a server asks for its signatures, without a second round trip. +#[tokio::test(flavor = "multi_thread")] +async fn naming_a_server_returns_declarations() { + let workspace = workspace_with_backing_server(); + let client = connect_in(&workspace).await; + + let result = client + .call_tool(CallToolRequestParams::new("list_tools").with_arguments( + serde_json::Map::from_iter([("servers".to_string(), serde_json::json!(["sqlx"]))]), + )) + .await + .expect("list_tools"); + let text = text_of(&result); + + assert!(text.contains("declare const sqlx"), "got: {text}"); + assert!(text.contains("Promise"), "got: {text}"); + assert!( + text.contains(r#""migrate-status""#) && text.contains("migrate_status"), + "both spellings should be declared, got: {text}" + ); + let _ = client.cancel().await; +} + +/// The design's whole claim: several tool calls, the filtering between them, +/// and one round trip — with intermediate data never reaching the agent. +#[tokio::test(flavor = "multi_thread")] +async fn a_script_composes_calls_in_one_round_trip() { + let workspace = workspace_with_backing_server(); + let client = connect_in(&workspace).await; + + let script = r#" + const a = await sqlx.query({ sql: "SELECT 1" }); + console.log("intermediate", a); + const b = await sqlx["migrate-status"](); + return { echoed: a.sql, status: b }; + "#; + let result = client + .call_tool(CallToolRequestParams::new("execute").with_arguments( + serde_json::Map::from_iter([("script".to_string(), serde_json::json!(script))]), + )) + .await + .expect("execute"); + let text = text_of(&result); + + assert_ne!(result.is_error, Some(true), "got: {text}"); + assert!(text.contains(r#""echoed":"SELECT 1""#), "got: {text}"); + assert!(text.contains(r#""status":"up to date""#), "got: {text}"); + assert!( + text.contains("console:") && text.contains("intermediate"), + "console output should be reported, got: {text}" + ); + let _ = client.cancel().await; +} + +/// Every name in the declarations has to dispatch to the tool it was declared +/// for, including a sanitized alias that collided with a real tool's name. +#[tokio::test(flavor = "multi_thread")] +async fn a_declared_name_reaches_the_tool_it_was_declared_for() { + let workspace = workspace_serving(serde_json::json!({ + "name": "sqlx", + "tools": [ + {"name": "get-sum", "description": "hyphenated", + "behavior": {"kind": "text", "text": "FROM-HYPHENATED"}}, + {"name": "get_sum", "description": "underscored", + "behavior": {"kind": "text", "text": "FROM-UNDERSCORED"}} + ] + })); + let client = connect_in(&workspace).await; + + let declarations = text_of( + &client + .call_tool(CallToolRequestParams::new("list_tools").with_arguments( + serde_json::Map::from_iter([("detail".to_string(), serde_json::json!("full"))]), + )) + .await + .expect("list_tools"), + ); + + let script = r#" + return { + viaAlias: await sqlx.get_sum_2(), + viaQuoted: await sqlx["get-sum"](), + viaOwnName: await sqlx.get_sum(), + }; + "#; + let result = client + .call_tool(CallToolRequestParams::new("execute").with_arguments( + serde_json::Map::from_iter([("script".to_string(), serde_json::json!(script))]), + )) + .await + .expect("execute"); + let text = text_of(&result); + + assert!( + declarations.contains("get_sum_2"), + "the alias should be declared, got: {declarations}" + ); + assert_ne!(result.is_error, Some(true), "got: {text}"); + assert!( + text.contains(r#""viaAlias":"FROM-HYPHENATED""#), + "the declared alias must reach the hyphenated tool, got: {text}" + ); + assert!( + text.contains(r#""viaQuoted":"FROM-HYPHENATED""#), + "got: {text}" + ); + assert!( + text.contains(r#""viaOwnName":"FROM-UNDERSCORED""#), + "a real tool must keep its own name, got: {text}" + ); + let _ = client.cancel().await; +} + +/// A script that touches one server must not start the others, or it pays +/// for -- and can be blocked by -- servers it never mentions. +#[tokio::test(flavor = "multi_thread")] +async fn a_script_starts_only_the_servers_it_calls() { + let dir = tempfile::tempdir().expect("temp dir"); + let base = dir.path().to_path_buf(); + let home = base.join("home"); + let root = base.join("ws"); + let started = base.join("started.log"); + std::fs::create_dir_all(home.join("plugins/db")).unwrap(); + std::fs::create_dir_all(root.join("src")).unwrap(); + + let mut configs = Vec::new(); + for name in ["used", "unused"] { + let path = base.join(format!("{name}.json")); + std::fs::write( + &path, + serde_json::json!({ + "name": name, + "startup_log": started, + "tools": [ + {"name": "ping", "description": "answer", + "behavior": {"kind": "text", "text": name}} + ] + }) + .to_string(), + ) + .unwrap(); + configs.push(path); + } + + let mut manifest = String::from("name = \"db-plugin\"\ndepends-on = [\"*\"]\n"); + for (name, config) in ["used", "unused"].iter().zip(&configs) { + manifest.push_str(&format!( + "\n[[mcp_servers]]\nname = {:?}\ncommand = {:?}\nargs = [\"--config\", {:?}]\n", + name, + mock_binary().display().to_string(), + config.display().to_string(), + )); + } + std::fs::write(home.join("plugins/db/SYMPOSIUM.toml"), manifest).unwrap(); + std::fs::write( + home.join("config.toml"), + "hook-scope = \"project\"\n[defaults]\nsymposium-recommendations = false\n", + ) + .unwrap(); + std::fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"e2e\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\ + [dependencies]\nserde = \"1\"\n", + ) + .unwrap(); + std::fs::write(root.join("src/lib.rs"), "// lib\n").unwrap(); + + let workspace = Workspace { + _dir: dir, + home, + root, + }; + let client = connect_in(&workspace).await; + + let result = client + .call_tool(CallToolRequestParams::new("execute").with_arguments( + serde_json::Map::from_iter([( + "script".to_string(), + serde_json::json!("return await used.ping();"), + )]), + )) + .await + .expect("execute"); + let text = text_of(&result); + assert!(text.contains("used"), "got: {text}"); + + let log = std::fs::read_to_string(&started).unwrap_or_default(); + assert!( + log.contains("used"), + "the called server should have started" + ); + assert!( + !log.contains("unused"), + "a server the script never named was started: {log}" + ); + let _ = client.cancel().await; +} + +/// A dependency added mid-session exposes its tools without a restart, and a +/// server that is still applicable is carried across rather than restarted -- +/// its startup log must still show a single start. +#[tokio::test(flavor = "multi_thread")] +async fn a_dependency_added_mid_session_appears() { + let dir = tempfile::tempdir().expect("temp dir"); + let base = dir.path().to_path_buf(); + let home = base.join("home"); + let root = base.join("ws"); + let started = base.join("started.log"); + std::fs::create_dir_all(home.join("plugins/db")).unwrap(); + std::fs::create_dir_all(root.join("src")).unwrap(); + + let mut manifest = String::from("name = \"db-plugin\"\ndepends-on = [\"*\"]\n"); + for (name, dep) in [("always", "serde"), ("later", "regex")] { + let config = base.join(format!("{name}.json")); + std::fs::write( + &config, + serde_json::json!({ + "name": name, + "startup_log": started, + "tools": [ + {"name": "ping", "description": "answer", + "behavior": {"kind": "text", "text": name}} + ] + }) + .to_string(), + ) + .unwrap(); + manifest.push_str(&format!( + "\n[[mcp_servers]]\nname = {:?}\ndepends-on = [{:?}]\ncommand = {:?}\n\ + args = [\"--config\", {:?}]\n", + name, + dep, + mock_binary().display().to_string(), + config.display().to_string(), + )); + } + std::fs::write(home.join("plugins/db/SYMPOSIUM.toml"), manifest).unwrap(); + std::fs::write( + home.join("config.toml"), + "hook-scope = \"project\"\n[defaults]\nsymposium-recommendations = false\n", + ) + .unwrap(); + + let cargo_toml = root.join("Cargo.toml"); + std::fs::write( + &cargo_toml, + "[package]\nname = \"e2e\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\ + [dependencies]\nserde = \"1\"\n", + ) + .unwrap(); + std::fs::write(root.join("src/lib.rs"), "// lib\n").unwrap(); + + let workspace = Workspace { + _dir: dir, + home, + root: root.clone(), + }; + let client = connect_in(&workspace).await; + + let first = text_of( + &client + .call_tool(CallToolRequestParams::new("list_tools")) + .await + .expect("list_tools"), + ); + assert!(first.contains("always"), "got: {first}"); + assert!( + !first.contains("later"), + "not a dependency yet, got: {first}" + ); + + // Add the dependency the second server is gated on, exactly as `cargo + // add` would, and let cargo rewrite the lock file. + std::fs::write( + &cargo_toml, + "[package]\nname = \"e2e\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\ + [dependencies]\nserde = \"1\"\nregex = \"1\"\n", + ) + .unwrap(); + let generated = std::process::Command::new("cargo") + .args(["generate-lockfile", "--offline"]) + .current_dir(&root) + .output(); + if !generated.map(|o| o.status.success()).unwrap_or(false) { + eprintln!("skipping: cargo could not resolve `regex` offline"); + let _ = client.cancel().await; + return; + } + + let second = text_of( + &client + .call_tool(CallToolRequestParams::new("list_tools")) + .await + .expect("list_tools"), + ); + assert!( + second.contains("later"), + "a newly applicable server should appear, got: {second}" + ); + + let log = std::fs::read_to_string(&started).unwrap_or_default(); + assert_eq!( + log.matches("always").count(), + 1, + "the still-applicable server was restarted: {log}" + ); + let _ = client.cancel().await; +} + +/// A limit the script exceeded is reported in a form it can act on, rather +/// than as prose it has to interpret. +#[tokio::test(flavor = "multi_thread")] +async fn exceeding_a_limit_reports_a_tagged_error() { + let workspace = workspace_with_backing_server(); + std::fs::write( + workspace.home.join("config.toml"), + "hook-scope = \"project\"\n[defaults]\nsymposium-recommendations = false\n\ + [mcp]\nscript-timeout-secs = 2\ntool-call-timeout-secs = 1\n", + ) + .unwrap(); + let client = connect_in(&workspace).await; + + let result = client + .call_tool(CallToolRequestParams::new("execute").with_arguments( + serde_json::Map::from_iter([( + "script".to_string(), + serde_json::json!("while (true) {}"), + )]), + )) + .await + .expect("execute should answer, not fail"); + let text = text_of(&result); + + assert_eq!(result.is_error, Some(true), "got: {text}"); + assert!(text.contains("script_timeout"), "got: {text}"); + let _ = client.cancel().await; +} + +/// A script can end with work outstanding: a promise nothing settles, or a +/// tool call it forgot to await. Both must still produce a reply. +#[tokio::test(flavor = "multi_thread")] +async fn a_script_left_pending_still_answers() { + let workspace = workspace_with_backing_server(); + std::fs::write( + workspace.home.join("config.toml"), + "hook-scope = \"project\"\n[defaults]\nsymposium-recommendations = false\n\ + [mcp]\nscript-timeout-secs = 2\ntool-call-timeout-secs = 1\n", + ) + .unwrap(); + let client = connect_in(&workspace).await; + + for script in [ + "return new Promise(() => {});", + // Dispatched but never awaited. + "sqlx.query({ sql: \"SELECT 1\" }); return \"done\";", + ] { + let result = tokio::time::timeout( + Duration::from_secs(30), + client.call_tool(CallToolRequestParams::new("execute").with_arguments( + serde_json::Map::from_iter([("script".to_string(), serde_json::json!(script))]), + )), + ) + .await + .unwrap_or_else(|_| panic!("execute never answered for: {script}")) + .expect("execute should answer, not fail"); + + let text = text_of(&result); + assert!( + text.contains("script_timeout") || result.is_error != Some(true), + "expected an answer either way, got: {text}" + ); + } + let _ = client.cancel().await; +} + +/// The transport is newline-delimited JSON, so anything else written to +/// stdout corrupts the stream. Reporting output is the likely offender, since +/// every other subcommand sends it there. +#[tokio::test(flavor = "multi_thread")] +async fn stdout_carries_only_json_rpc() { + let home = isolated_home(); + + let mut child = tokio::process::Command::new(binary()) + .arg("mcp-serve") + // Verbose reporting would go to stdout for any other subcommand. + .arg("--verbose") + .env("SYMPOSIUM_HOME", home.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn"); + + use tokio::io::AsyncWriteExt; + let mut stdin = child.stdin.take().expect("stdin"); + stdin + .write_all( + concat!( + r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}"#, + "\n", + r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#, + "\n", + r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#, + "\n", + ) + .as_bytes(), + ) + .await + .expect("write"); + drop(stdin); + + let output = tokio::time::timeout(Duration::from_secs(30), child.wait_with_output()) + .await + .expect("server should exit on stdin close") + .expect("output"); + + let stdout = String::from_utf8(output.stdout).expect("utf-8"); + let lines: Vec<&str> = stdout.lines().filter(|l| !l.trim().is_empty()).collect(); + + assert_eq!(lines.len(), 2, "one response per request, got: {stdout}"); + for line in lines { + serde_json::from_str::(line) + .unwrap_or_else(|e| panic!("stdout line is not JSON ({e}): {line}")); + } +} + +/// An install command prints, and under `mcp-serve` stdout is the protocol. +#[tokio::test(flavor = "multi_thread")] +async fn install_command_output_stays_off_stdout() { + const MARKER: &str = "SYMPOSIUM-INSTALL-STDOUT-MARKER"; + + let home = isolated_home(); + let plugin_dir = home.path().join("plugins").join("noisy"); + std::fs::create_dir_all(&plugin_dir).expect("plugin dir"); + std::fs::write( + plugin_dir.join("SYMPOSIUM.toml"), + format!( + "name = \"noisy\"\n\ + depends-on = [\"*\"]\n\ + \n\ + [[installations]]\n\ + name = \"warmup\"\n\ + install_commands = [\"echo {MARKER}\"]\n\ + \n\ + [[mcp_servers]]\n\ + name = \"noisy-server\"\n\ + depends-on = [\"*\"]\n\ + command = \"/usr/bin/true\"\n\ + requirements = [\"warmup\"]\n" + ), + ) + .expect("write manifest"); + + // Server resolution needs a Rust workspace to condition on. + let workspace = tempfile::tempdir().expect("workspace"); + std::fs::write( + workspace.path().join("Cargo.toml"), + "[package]\nname = \"probe\"\nversion = \"0.0.0\"\nedition = \"2021\"\n", + ) + .expect("write Cargo.toml"); + std::fs::create_dir_all(workspace.path().join("src")).expect("src dir"); + std::fs::write(workspace.path().join("src/lib.rs"), "").expect("write lib.rs"); + + let mut child = tokio::process::Command::new(binary()) + .arg("mcp-serve") + .current_dir(workspace.path()) + .env("SYMPOSIUM_HOME", home.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn"); + + use tokio::io::AsyncWriteExt; + let mut stdin = child.stdin.take().expect("stdin"); + stdin + .write_all( + concat!( + r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}"#, + "\n", + r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#, + "\n", + // Starting the server is what acquires its requirements. + r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_tools","arguments":{"detail":"full"}}}"#, + "\n", + ) + .as_bytes(), + ) + .await + .expect("write"); + drop(stdin); + + let output = tokio::time::timeout(Duration::from_secs(60), child.wait_with_output()) + .await + .expect("server should exit on stdin close") + .expect("output"); + + let stdout = String::from_utf8(output.stdout).expect("utf-8"); + assert!( + !stdout.contains(MARKER), + "install command output reached the protocol stream:\n{stdout}" + ); + for line in stdout.lines().filter(|l| !l.trim().is_empty()) { + serde_json::from_str::(line) + .unwrap_or_else(|e| panic!("stdout line is not JSON ({e}): {line}")); + } +} + +// -- declared output schemas -- + +/// A workspace whose backing server declares an output schema on some tools. +/// +/// `echo` returns the arguments as structured content, so a script decides what +/// the server sends back. That is what lets one mock cover both a conforming +/// answer and a violating one. +fn workspace_with_typed_server() -> Workspace { + workspace_serving_as( + "typed", + serde_json::json!({ + "name": "typed", + "tools": [ + {"name": "count", "description": "Count things", + "inputSchema": {"type": "object", + "properties": {"count": {"type": "number"}}, "required": ["count"]}, + "outputSchema": {"type": "object", + "properties": {"count": {"type": "number"}}, "required": ["count"]}, + "behavior": {"kind": "echo"}}, + {"name": "count_items", "description": "A snake_case tool name", + "inputSchema": {"type": "object", "properties": {"bin": {"type": "string"}}}, + "behavior": {"kind": "echo"}}, + {"name": "untyped", "description": "Declares no output shape", + "inputSchema": {"type": "object", "properties": {"a": {"type": "string"}}}, + "behavior": {"kind": "echo"}}, + {"name": "unstructured", "description": "Declares a shape, answers with text", + "inputSchema": {"type": "object"}, + "outputSchema": {"type": "object", + "properties": {"count": {"type": "number"}}, "required": ["count"]}, + "behavior": {"kind": "text", "text": "count: 3"}} + ] + }), + ) +} + +async fn execute( + client: &rmcp::service::RunningService, + script: &str, +) -> rmcp::model::CallToolResult { + client + .call_tool(CallToolRequestParams::new("execute").with_arguments( + serde_json::Map::from_iter([("script".to_string(), serde_json::json!(script))]), + )) + .await + .expect("execute should answer, not fail") +} + +/// The declared shape reaches the model as the tool's return type. +#[tokio::test(flavor = "multi_thread")] +async fn a_declared_output_schema_is_shown_as_the_return_type() { + let workspace = workspace_with_typed_server(); + let client = connect_in(&workspace).await; + + let result = client + .call_tool(CallToolRequestParams::new("list_tools").with_arguments( + serde_json::Map::from_iter([("servers".to_string(), serde_json::json!(["typed"]))]), + )) + .await + .expect("list_tools"); + let text = text_of(&result); + + assert!( + text.contains("count(params: {") && text.contains("): Promise<{"), + "the typed tool should declare its return shape, got: {text}" + ); + assert!( + text.contains("untyped(params?: {\n a?: string;\n }): Promise;"), + "a tool without an output schema stays unknown, got: {text}" + ); + let _ = client.cancel().await; +} + +/// A conforming answer passes through untouched. +#[tokio::test(flavor = "multi_thread")] +async fn a_conforming_result_is_returned() { + let workspace = workspace_with_typed_server(); + let client = connect_in(&workspace).await; + + let result = execute(&client, r#"return await typed.count({ count: 3 });"#).await; + let text = text_of(&result); + + assert_ne!(result.is_error, Some(true), "got: {text}"); + assert!(text.contains(r#""count":3"#), "got: {text}"); + let _ = client.cancel().await; +} + +/// A server that declares a shape and sends another does not fail the call. +/// The value stands and the model is told the shape did not hold, because the +/// value is often still readable and only the model can decide. +#[tokio::test(flavor = "multi_thread")] +async fn a_violating_result_is_passed_through_with_a_notice() { + let workspace = workspace_with_typed_server(); + let client = connect_in(&workspace).await; + + // `echo` reflects the arguments, so this makes the server answer with a + // string where its own schema promised a number. + let result = execute(&client, r#"return await typed.count({ count: "three" });"#).await; + let text = text_of(&result); + + assert_ne!( + result.is_error, + Some(true), + "a schema mismatch must not fail the call, got: {text}" + ); + assert!( + text.contains(r#""count":"three""#), + "the value should reach the script unchanged, got: {text}" + ); + assert!( + text.contains("[typed.count: result off-shape, treat as unknown]"), + "the model should be tagged, tersely, got: {text}" + ); +} + +/// The case from the requirement: a tool declares `{ count: number }` and +/// answers `"count: 3"` as text. Ordinary MCP, so the text is handed over for +/// the model to read rather than refused. +#[tokio::test(flavor = "multi_thread")] +async fn an_unstructured_answer_is_handed_over_to_be_read() { + let workspace = workspace_with_typed_server(); + let client = connect_in(&workspace).await; + + let result = execute(&client, r#"return await typed.unstructured();"#).await; + let text = text_of(&result); + + assert_ne!( + result.is_error, + Some(true), + "text where an object was declared must not fail, got: {text}" + ); + assert!( + text.contains("count: 3"), + "the script should receive the text, got: {text}" + ); + assert!( + text.contains("[typed.unstructured: result off-shape, treat as unknown]"), + "the model should be tagged, tersely, got: {text}" + ); +} + +/// A mismatch is not an exception, so a script that destructures the declared +/// shape keeps running and simply finds nothing there. +#[tokio::test(flavor = "multi_thread")] +async fn a_violation_does_not_throw_in_the_script() { + let workspace = workspace_with_typed_server(); + let client = connect_in(&workspace).await; + + let script = r#" + const r = await typed.unstructured(); + return { raw: r, declared: r.count ?? "absent", threw: false }; + "#; + let result = execute(&client, script).await; + let text = text_of(&result); + + assert_ne!(result.is_error, Some(true), "got: {text}"); + assert!(text.contains(r#""threw":false"#), "got: {text}"); + assert!( + text.contains(r#""declared":"absent""#), + "destructuring the declared shape should find nothing, got: {text}" + ); + assert!(text.contains(r#""raw":"count: 3""#), "got: {text}"); +} + +/// One tool answering off-shape repeatedly is one fact about that tool. The +/// notice must not be repeated per call, or a loop would crowd out the result. +#[tokio::test(flavor = "multi_thread")] +async fn a_repeated_violation_is_reported_once() { + let workspace = workspace_with_typed_server(); + let client = connect_in(&workspace).await; + + let script = r#" + for (let i = 0; i < 4; i++) { await typed.unstructured(); } + return "done"; + "#; + let result = execute(&client, script).await; + let text = text_of(&result); + + assert_ne!(result.is_error, Some(true), "got: {text}"); + assert_eq!( + text.matches("result off-shape").count(), + 1, + "the tag should appear once, got: {text}" + ); +} + +/// A tool that declares nothing is unaffected: no shape was promised, so +/// nothing is enforced. +#[tokio::test(flavor = "multi_thread")] +async fn an_untyped_tool_is_not_checked() { + let workspace = workspace_with_typed_server(); + let client = connect_in(&workspace).await; + + let result = execute(&client, r#"return await typed.untyped({ a: "anything" });"#).await; + let text = text_of(&result); + + assert_ne!(result.is_error, Some(true), "got: {text}"); + assert!(text.contains(r#""a":"anything""#), "got: {text}"); + let _ = client.cancel().await; +} + +/// Tool names come from third-party servers and none in the wild are camelCase, +/// so a model reaching for one out of TypeScript habit must still land the call. +#[tokio::test(flavor = "multi_thread")] +async fn a_camel_case_spelling_reaches_a_snake_case_tool() { + let workspace = workspace_with_typed_server(); + let client = connect_in(&workspace).await; + + let result = execute(&client, r#"return await typed.countItems({ bin: "A1" });"#).await; + let text = text_of(&result); + + assert_ne!(result.is_error, Some(true), "got: {text}"); + assert!(text.contains(r#""bin":"A1""#), "got: {text}"); + let _ = client.cancel().await; +} + +/// The declared spelling is what the model is shown, and it keeps working. +#[tokio::test(flavor = "multi_thread")] +async fn the_declared_spelling_still_reaches_its_tool() { + let workspace = workspace_with_typed_server(); + let client = connect_in(&workspace).await; + + let result = execute(&client, r#"return await typed.count_items({ bin: "A1" });"#).await; + let text = text_of(&result); + + assert_ne!(result.is_error, Some(true), "got: {text}"); + assert!(text.contains(r#""bin":"A1""#), "got: {text}"); + let _ = client.cancel().await; +} + +/// Tolerant lookup must not invent tools: an unknown name still fails, with the +/// existing did-you-mean rather than a silent wrong call. +#[tokio::test(flavor = "multi_thread")] +async fn an_unknown_tool_name_still_fails() { + let workspace = workspace_with_typed_server(); + let client = connect_in(&workspace).await; + + let result = execute(&client, r#"return await typed.deleteEverything();"#).await; + let text = text_of(&result); + + assert_eq!(result.is_error, Some(true), "got: {text}"); + let _ = client.cancel().await; +} + +/// Only declared names appear; a tolerated spelling is not advertised. +#[tokio::test(flavor = "multi_thread")] +async fn an_alternate_spelling_is_not_declared() { + let workspace = workspace_with_typed_server(); + let client = connect_in(&workspace).await; + + let result = client + .call_tool(CallToolRequestParams::new("list_tools").with_arguments( + serde_json::Map::from_iter([("servers".to_string(), serde_json::json!(["typed"]))]), + )) + .await + .expect("list_tools"); + let text = text_of(&result); + + assert!(text.contains("count_items("), "got: {text}"); + assert!( + !text.contains("countItems"), + "the camelCase spelling must not be declared, got: {text}" + ); + let _ = client.cancel().await; +}