diff --git a/.gitignore b/.gitignore index fba33d24f..236a4ac63 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,5 @@ clients/generated/smithy/typescript/tsconfig.* *.dylib *.so *.dll +smithy/mcp-codegen/build/ +smithy/mcp-codegen/.gradle/ diff --git a/Cargo.lock b/Cargo.lock index 2e68e84ba..04cbe3868 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rand", + "rand 0.8.5", "sha1", "smallvec", "tokio", @@ -224,15 +224,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "addr2line" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a76fd60b23679b7d19bd066031410fb7e458ccc5e958eb5c325888ce4baedc97" -dependencies = [ - "gimli", -] - [[package]] name = "adler" version = "1.0.2" @@ -257,7 +248,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.7", ] [[package]] @@ -327,12 +318,6 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -442,7 +427,7 @@ dependencies = [ "rustc-hash 2.1.1", "serde", "serde_derive", - "syn 2.0.103", + "syn 2.0.117", ] [[package]] @@ -475,18 +460,40 @@ checksum = "5fd55a5ba1179988837d24ab4c7cc8ed6efdeff578ede0416b4225a5fca35bd0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.117", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] name = "async-trait" -version = "0.1.83" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.117", ] [[package]] @@ -506,7 +513,7 @@ dependencies = [ "manyhow", "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.117", ] [[package]] @@ -522,7 +529,7 @@ dependencies = [ "proc-macro2", "quote", "quote-use", - "syn 2.0.103", + "syn 2.0.117", ] [[package]] @@ -568,7 +575,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rand", + "rand 0.8.5", "serde", "serde_json", "serde_urlencoded", @@ -1009,21 +1016,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "backtrace" -version = "0.3.67" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233d376d6d185f2a3093e58f283f60f880315b6c60075b01f36b3b85154564ca" -dependencies = [ - "addr2line", - "cc", - "cfg-if", - "libc", - "miniz_oxide 0.6.2", - "object", - "rustc-demangle", -] - [[package]] name = "base16ct" version = "0.2.0" @@ -1104,7 +1096,7 @@ dependencies = [ "regex", "rustc-hash 1.1.0", "shlex", - "syn 2.0.103", + "syn 2.0.117", "which", ] @@ -1157,6 +1149,31 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bon" +version = "3.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f47dbe92550676ee653353c310dfb9cf6ba17ee70396e1f7cf0a2020ad49b2fe" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" +dependencies = [ + "darling 0.23.0", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", +] + [[package]] name = "brotli" version = "3.3.4" @@ -1442,19 +1459,29 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", +] + [[package]] name = "chrono" -version = "0.4.34" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bc015644b92d5890fab7489e49d21f879d5c990186827d42ec511919404f38b" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits", "serde", "wasm-bindgen", - "windows-targets 0.52.0", + "windows-link", ] [[package]] @@ -1554,7 +1581,7 @@ dependencies = [ "heck 0.4.1", "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.117", ] [[package]] @@ -1783,6 +1810,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc16" version = "0.4.0" @@ -1835,7 +1871,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -1847,7 +1883,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] @@ -1867,7 +1903,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.7", "curve25519-dalek-derive", "digest", "fiat-crypto", @@ -1884,7 +1920,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.117", ] [[package]] @@ -1911,7 +1947,7 @@ dependencies = [ "proc-macro2", "quote", "scratch", - "syn 2.0.103", + "syn 2.0.117", ] [[package]] @@ -1928,7 +1964,7 @@ checksum = "2345488264226bf682893e25de0769f3360aac9957980ec49361b083ddaa5bc5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.117", ] [[package]] @@ -1951,6 +1987,16 @@ dependencies = [ "darling_macro 0.20.10", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + [[package]] name = "darling_core" version = "0.14.4" @@ -1976,7 +2022,20 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.103", + "syn 2.0.117", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.117", ] [[package]] @@ -1998,7 +2057,18 @@ checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ "darling_core 0.20.10", "quote", - "syn 2.0.103", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.117", ] [[package]] @@ -2049,7 +2119,7 @@ checksum = "146398d62142a0f35248a608f17edf0dde57338354966d6e41d0eb2d16980ccb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.117", ] [[package]] @@ -2084,7 +2154,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.103", + "syn 2.0.117", "unicode-xid", ] @@ -2139,7 +2209,7 @@ dependencies = [ "dsl_auto_type", "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.117", ] [[package]] @@ -2148,7 +2218,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "209c735641a413bc68c4923a9d6ad4bcb3ca306b794edaa7eb0b3228a99ffb25" dependencies = [ - "syn 2.0.103", + "syn 2.0.117", ] [[package]] @@ -2204,7 +2274,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.117", ] [[package]] @@ -2278,7 +2348,7 @@ dependencies = [ "hkdf", "pem-rfc7468", "pkcs8", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", @@ -2417,7 +2487,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ded41244b729663b1e574f1b4fb731469f69f79c17667b5d776b16cda0479449" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -2446,7 +2516,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b9429470923de8e8cbd4d2dc513535400b4b3fef0319fb5c4e1f520a7bef743" dependencies = [ "crc32fast", - "miniz_oxide 0.7.1", + "miniz_oxide", ] [[package]] @@ -2464,6 +2534,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.3.2" @@ -2520,7 +2596,7 @@ dependencies = [ "futures", "log", "parking_lot", - "rand", + "rand 0.8.5", "redis-protocol", "semver", "socket2 0.5.10", @@ -2539,7 +2615,7 @@ checksum = "1458c6e22d36d61507034d5afecc64f105c1d39712b7ac6ec3b352c423f715cc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.117", ] [[package]] @@ -2643,7 +2719,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.117", ] [[package]] @@ -2709,11 +2785,25 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasi 0.14.2+wasi-0.2.4", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.0", + "wasip2", + "wasip3", +] + [[package]] name = "ghash" version = "0.5.1" @@ -2724,12 +2814,6 @@ dependencies = [ "polyval", ] -[[package]] -name = "gimli" -version = "0.27.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c80984affa11d98d1b88b66ac8853f143217b399d3c74116778ff8fdb4ed2e" - [[package]] name = "glob" version = "0.3.1" @@ -2801,7 +2885,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -2881,6 +2965,15 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -3183,6 +3276,12 @@ dependencies = [ "cxx-build", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -3451,6 +3550,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "leptos" version = "0.6.11" @@ -3550,7 +3655,7 @@ dependencies = [ "quote", "rstml", "serde", - "syn 2.0.103", + "syn 2.0.117", "walkdir", ] @@ -3586,7 +3691,7 @@ dependencies = [ "quote", "rstml", "server_fn_macro", - "syn 2.0.103", + "syn 2.0.117", "tracing", "uuid", ] @@ -3800,7 +3905,7 @@ dependencies = [ "manyhow-macros", "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.117", ] [[package]] @@ -3876,15 +3981,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" -[[package]] -name = "miniz_oxide" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b275950c28b37e794e8c55d88aeb5e139d0ce23fdbbeda68f8d7174abdf9e8fa" -dependencies = [ - "adler", -] - [[package]] name = "miniz_oxide" version = "0.7.1" @@ -3941,7 +4037,7 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.117", ] [[package]] @@ -4034,7 +4130,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.5", "smallvec", "zeroize", ] @@ -4123,7 +4219,7 @@ dependencies = [ "chrono", "getrandom 0.2.15", "http 0.2.9", - "rand", + "rand 0.8.5", "reqwest", "serde", "serde_json", @@ -4133,15 +4229,6 @@ dependencies = [ "url", ] -[[package]] -name = "object" -version = "0.30.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03b4680b86d9cfafba8fc491dc9b6df26b68cf40e9e6cd73909194759a63c385" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.18.0" @@ -4186,7 +4273,7 @@ dependencies = [ "oauth2", "p256", "p384", - "rand", + "rand 0.8.5", "rsa", "serde", "serde-value", @@ -4224,7 +4311,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.117", ] [[package]] @@ -4325,6 +4412,12 @@ version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f746c4065a8fa3fe23974dd82f15431cc8d40779821001404d10d2e79ca7d79" +[[package]] +name = "pastey" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" + [[package]] name = "pathdiff" version = "0.2.1" @@ -4377,7 +4470,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.117", ] [[package]] @@ -4418,7 +4511,7 @@ checksum = "4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.117", ] [[package]] @@ -4473,7 +4566,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.7", "opaque-debug", "universal-hash", ] @@ -4537,12 +4630,12 @@ dependencies = [ [[package]] name = "prettyplease" -version = "0.2.15" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae005bd773ab59b4725093fd7df83fd7892f7d8eafb48dbd7de6e024e4215f9d" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.103", + "syn 2.0.117", ] [[package]] @@ -4614,7 +4707,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.117", "version_check", "yansi", ] @@ -4659,7 +4752,7 @@ dependencies = [ "proc-macro-utils", "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.117", ] [[package]] @@ -4668,6 +4761,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "r2d2" version = "0.8.10" @@ -4687,7 +4786,18 @@ checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.0", ] [[package]] @@ -4697,7 +4807,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -4709,6 +4819,12 @@ dependencies = [ "getrandom 0.2.15", ] +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + [[package]] name = "redis-protocol" version = "5.0.1" @@ -4741,6 +4857,26 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "regex" version = "1.9.4" @@ -4873,7 +5009,7 @@ checksum = "a5a11a05ee1ce44058fa3d5961d05194fdbe3ad6b40f904af764d81b86450e6b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.117", ] [[package]] @@ -4906,6 +5042,68 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba6b9d2f0efe2258b23767f1f9e0054cfbcac9c2d6f81a031214143096d7864f" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bytes", + "chrono", + "futures", + "http 1.1.0", + "http-body 1.0.1", + "http-body-util", + "pastey", + "pin-project-lite", + "rand 0.10.0", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.12", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp-actix-web" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85f125d3ab16dad1f7fb9542e3ebe93f25eefee847a03692b7eb37134104cf3d" +dependencies = [ + "actix-web", + "async-stream", + "bon", + "futures", + "rmcp", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab9d95d7ed26ad8306352b0d5f05b593222b272790564589790d210aa15caa9e" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.117", +] + [[package]] name = "roxmltree" version = "0.14.1" @@ -4934,7 +5132,7 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", @@ -4950,17 +5148,11 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.103", + "syn 2.0.117", "syn_derive", "thiserror 1.0.58", ] -[[package]] -name = "rustc-demangle" -version = "0.1.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" - [[package]] name = "rustc-hash" version = "1.1.0" @@ -5101,9 +5293,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.12" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f3208ce4d8448b3f3e7d168a73f5e0c43a61e32930de3bceeccedb388b6bf06" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" @@ -5138,6 +5330,32 @@ dependencies = [ "parking_lot", ] +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + [[package]] name = "scopeguard" version = "1.1.0" @@ -5167,7 +5385,7 @@ checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.117", ] [[package]] @@ -5317,7 +5535,18 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -5429,7 +5658,7 @@ dependencies = [ "darling 0.20.10", "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.117", ] [[package]] @@ -5473,7 +5702,7 @@ dependencies = [ "convert_case 0.6.0", "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.117", "xxhash-rust", ] @@ -5484,7 +5713,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4ad11700cbccdbd313703916eb8c97301ee423c4a06e5421b77956fdcb36a9f" dependencies = [ "server_fn_macro", - "syn 2.0.103", + "syn 2.0.117", ] [[package]] @@ -5508,7 +5737,7 @@ dependencies = [ "log", "once_cell", "openidconnect", - "rand", + "rand 0.8.5", "regex", "reqwest", "rs-snowflake", @@ -5533,7 +5762,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f04293dc80c3993519f2d7f6f511707ee7094fe0c6d3406feb330cdb3540eba3" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.7", "digest", ] @@ -5544,7 +5773,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82e6b795fe2e3b1e845bafcb27aa35405c4d47cdfc92af5fc8d3002f76cebdc0" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.7", "digest", ] @@ -5579,7 +5808,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -5669,6 +5898,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "spin" version = "0.5.2" @@ -5691,6 +5930,19 @@ dependencies = [ "der", ] +[[package]] +name = "sse-stream" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb4dc4d33c68ec1f27d386b5610a351922656e1fdf5c05bbaad930cd1519479a" +dependencies = [ + "bytes", + "futures-util", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -5725,7 +5977,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.103", + "syn 2.0.117", ] [[package]] @@ -5764,6 +6016,7 @@ dependencies = [ "service_utils", "superposition_derives", "superposition_macros", + "superposition_mcp", "superposition_types", "tracing", "tracing-actix-web", @@ -5814,13 +6067,32 @@ version = "0.100.2" dependencies = [ "proc-macro-crate", "quote", - "syn 2.0.103", + "syn 2.0.117", ] [[package]] name = "superposition_macros" version = "0.100.2" +[[package]] +name = "superposition_mcp" +version = "0.100.2" +dependencies = [ + "actix-web", + "anyhow", + "aws-smithy-runtime-api", + "aws-smithy-types", + "rmcp", + "rmcp-actix-web", + "schemars", + "serde", + "serde_json", + "superposition_sdk", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "superposition_provider" version = "0.100.2" @@ -5965,9 +6237,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.103" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4307e30089d6fd6aff212f2da3a1f9e32f3223b1f010fb09b7c95f90f3ca1e8" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -5983,7 +6255,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.117", ] [[package]] @@ -6092,7 +6364,7 @@ checksum = "c61f3ba182994efc43764a46c018c347bc492c79f024e705f46567b418f6d4f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.117", ] [[package]] @@ -6103,7 +6375,7 @@ checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.117", ] [[package]] @@ -6172,31 +6444,30 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.40.0" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ - "backtrace", "bytes", "libc", "mio 1.0.2", "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.5.10", + "socket2 0.6.3", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.4.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.117", ] [[package]] @@ -6242,16 +6513,15 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.8" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "806fe8c2c87eccc8b3267cbae29ed3ab2d0bd37fca70ab622e46aaa9375ddb7d" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", "futures-sink", "pin-project-lite", "tokio", - "tracing", ] [[package]] @@ -6383,7 +6653,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.117", ] [[package]] @@ -6476,7 +6746,7 @@ checksum = "563b3b88238ec95680aef36bdece66896eaa7ce3c0f1b4f39d38fb2435261352" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.117", ] [[package]] @@ -6487,7 +6757,7 @@ checksum = "3c36781cc0e46a83726d9879608e4cf6c2505237e263a8eb8c24502989cfdb28" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.117", ] [[package]] @@ -6614,7 +6884,7 @@ dependencies = [ "indexmap 2.12.1", "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.117", ] [[package]] @@ -6629,7 +6899,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.103", + "syn 2.0.117", "toml 0.5.11", "uniffi_meta", ] @@ -6836,6 +7106,24 @@ dependencies = [ "wit-bindgen-rt", ] +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.100" @@ -6858,7 +7146,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -6893,7 +7181,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.117", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -6928,7 +7216,29 @@ checksum = "17d5042cc5fa009658f9a7333ef24291b1291a25b6382dd68862a7f3b969f69b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.117", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.12.1", + "wasm-encoder", + "wasmparser", ] [[package]] @@ -6944,6 +7254,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.9.1", + "hashbrown 0.15.5", + "indexmap 2.12.1", + "semver", +] + [[package]] name = "web-sys" version = "0.3.77" @@ -7021,6 +7343,12 @@ dependencies = [ "windows-targets 0.48.0", ] +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-sys" version = "0.42.0" @@ -7063,6 +7391,15 @@ dependencies = [ "windows-targets 0.52.0", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.42.2" @@ -7262,6 +7599,26 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + [[package]] name = "wit-bindgen-rt" version = "0.39.0" @@ -7271,6 +7628,74 @@ dependencies = [ "bitflags 2.9.1", ] +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.12.1", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.9.1", + "indexmap 2.12.1", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.12.1", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "xmlparser" version = "0.13.6" diff --git a/Cargo.toml b/Cargo.toml index ee3901618..0aacadaa4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ members = [ "crates/superposition_core", "crates/superposition_provider", "crates/superposition_sdk", + "crates/superposition_mcp", "examples/experimentation_client_integration_example", "examples/cac_client_integration_example", "examples/superposition-demo-app", diff --git a/crates/frontend/src/components/default_config_form.rs b/crates/frontend/src/components/default_config_form.rs index 531545470..2f44c9602 100644 --- a/crates/frontend/src/components/default_config_form.rs +++ b/crates/frontend/src/components/default_config_form.rs @@ -557,7 +557,7 @@ pub fn DefaultConfigForm( view! { @@ -571,7 +571,7 @@ pub fn DefaultConfigForm( #[derive(Clone)] pub enum ChangeType { Delete, - Update(DefaultConfigUpdateRequest), + Update(Box), } #[component] diff --git a/crates/superposition/Cargo.toml b/crates/superposition/Cargo.toml index 26fa4bada..8a4c10c15 100644 --- a/crates/superposition/Cargo.toml +++ b/crates/superposition/Cargo.toml @@ -7,7 +7,12 @@ rust-version.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[features] +default = [] +mcp = ["superposition_mcp"] + [dependencies] +superposition_mcp = { path = "../superposition_mcp", features = ["actix"], optional = true } actix-files = { version = "0.6" } actix-web = { workspace = true } anyhow = { workspace = true } diff --git a/crates/superposition/src/main.rs b/crates/superposition/src/main.rs index cac15e002..78a016333 100644 --- a/crates/superposition/src/main.rs +++ b/crates/superposition/src/main.rs @@ -6,6 +6,9 @@ mod resolve; mod webhooks; mod workspace; +#[cfg(feature = "mcp")] +use superposition_mcp as _; + use std::{io::Result, time::Duration}; use actix_files::Files; @@ -146,36 +149,54 @@ async fn main() -> Result<()> { let auth_z = AuthZHandler::init(&kms_client, &app_env).await; let auth_z_manager = AuthZManager::init(&kms_client, &app_env).await; + // MCP server (optional, enabled via `mcp` feature + SUPERPOSITION_MCP=true env var) + #[cfg(feature = "mcp")] + let mcp_service = { + if get_from_env_or_default("SUPERPOSITION_MCP", false) { + match superposition_mcp::McpServerConfig::from_env() { + Ok(mcp_config) => { + tracing::info!("MCP server enabled at {}/mcp", base); + Some(superposition_mcp::actix::mcp_scope(mcp_config)) + } + Err(e) => { + tracing::warn!("MCP server disabled: {e}"); + None + } + } + } else { + None + } + }; + HttpServer::new(move || { let leptos_options = &conf.leptos_options; let site_root = &leptos_options.site_root; let leptos_envs = ui_envs.clone(); - App::new() - .app_data(app_state.clone()) - .app_data(PathConfig::default().error_handler(|err, _| bad_argument!(err).into())) - .app_data(QueryConfig::default().error_handler(|err, _| bad_argument!(err).into())) - .leptos_routes( - leptos_options.to_owned(), - routes.to_owned(), - move || { - provide_context(use_request_headers()); - view! { } - }, + + #[allow(unused_mut)] + let mut base_scope = scope(&base) + .route( + "/health", + get().to(|| async { HttpResponse::Ok().body("Health is good :D") }), ) - .service( - scope(&base) - .route( - "/health", - get().to(|| async { HttpResponse::Ok().body("Health is good :D") }), - ) - .service(auth_n.routes()) - .service(auth_n.org_routes()) - .service(web::redirect("", ui_redirect_path.to_string())) - .service(web::redirect("/", ui_redirect_path.to_string())) - .service(web::redirect("/admin", ui_redirect_path.to_string())) - .service(web::redirect("/admin/", ui_redirect_path.to_string())) - .service(web::redirect("/admin/{org_id}/", "workspaces")) - .service(web::redirect("/admin/{org_id}/{tenant}/", "default-config")) + .service(auth_n.routes()) + .service(auth_n.org_routes()) + .service(web::redirect("", ui_redirect_path.to_string())) + .service(web::redirect("/", ui_redirect_path.to_string())) + .service(web::redirect("/admin", ui_redirect_path.to_string())) + .service(web::redirect("/admin/", ui_redirect_path.to_string())) + .service(web::redirect("/admin/{org_id}/", "workspaces")) + .service(web::redirect("/admin/{org_id}/{tenant}/", "default-config")); + + // Mount MCP endpoint if enabled + #[cfg(feature = "mcp")] + if let Some(ref mcp) = mcp_service { + base_scope = base_scope.service( + web::scope("/mcp").service(mcp.clone().scope()), + ); + } + + let base_scope = base_scope /***************************** V1 Routes *****************************/ .service( scope("/context") @@ -279,7 +300,21 @@ async fn main() -> Result<()> { // serve other assets from the `assets` directory .service(Files::new("/assets", site_root.to_string())) // serve the favicon from /favicon.ico + ; + + App::new() + .app_data(app_state.clone()) + .app_data(PathConfig::default().error_handler(|err, _| bad_argument!(err).into())) + .app_data(QueryConfig::default().error_handler(|err, _| bad_argument!(err).into())) + .leptos_routes( + leptos_options.to_owned(), + routes.to_owned(), + move || { + provide_context(use_request_headers()); + view! { } + }, ) + .service(base_scope) .route( "/health", get().to(|| async { HttpResponse::Ok().body("Health is good :D") }), diff --git a/crates/superposition_mcp/CODEGEN_PLAN.md b/crates/superposition_mcp/CODEGEN_PLAN.md new file mode 100644 index 000000000..731e21e2e --- /dev/null +++ b/crates/superposition_mcp/CODEGEN_PLAN.md @@ -0,0 +1,329 @@ +# Smithy-to-MCP Deterministic Code Generator Plan + +## Problem + +Each MCP tool file manually declares parameter structs, SDK builder calls, and +response formatting that can be derived from the Smithy model. When a new +operation or service is added to Smithy, an engineer must manually write the +corresponding MCP tool code — ~200 lines of boilerplate per resource. + +## Approach: Smithy Codegen Plugin + +Write a **custom Smithy codegen plugin** (Java/Kotlin) that reads the same +`.smithy` models and emits Rust source files for the MCP crate. This runs +alongside the existing `rust-client-codegen` plugin in `smithy-build.json`. + +### Why a Smithy Plugin (vs. Parsing Rust SDK Output) + +- Smithy's Java model API gives structured access to operations, shapes, + traits (`@http`, `@httpQuery`, `@required`, `@documentation`, etc.) +- No fragile regex/AST parsing of generated Rust code +- Runs as part of the existing `smithy build` pipeline +- Same approach used by the 6 existing codegen plugins + +## Generated Artifacts + +For each Smithy resource (e.g., `Context`, `Dimension`), generate: + +### 1. `tools/{resource}.rs` — Parameter Structs + Impl + +``` +// AUTO-GENERATED — DO NOT EDIT +// Source: smithy/models/context.smithy + +use serde::Deserialize; +use schemars::JsonSchema; + +/// Params for CreateContext +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateContextParams { + /// The context conditions (required) + pub context: serde_json::Value, + /// Override values (required) + pub r#override: serde_json::Value, + /// Reason for the change (required) + pub change_reason: String, + /// Optional description + pub description: Option, + /// Config tags header + pub config_tags: Option, +} + +// ... more param structs for Get, List, Update, Delete ... + +impl SuperpositionMcpServer { + pub async fn create_context_impl( + &self, + args: CreateContextParams, + ) -> Result { + let ctx_map = json_to_doc_map(args.context).map_err(mcp_err)?; + let ovr_map = json_to_doc_map(args.r#override).map_err(mcp_err)?; + let mut put_builder = superposition_sdk::types::ContextPut::builder() + .set_context(Some(ctx_map)) + .set_override(Some(ovr_map)) + .change_reason(args.change_reason); + if let Some(d) = args.description { + put_builder = put_builder.description(d); + } + let put = put_builder.build().map_err(mcp_err)?; + let mut req = self.client + .create_context() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .request(put); + if let Some(tags) = args.config_tags { + req = req.config_tags(tags); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty( + &context_to_json!(resp) + ).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } +} +``` + +### 2. `server_tools.rs` — Tool Registration + +``` +// AUTO-GENERATED — DO NOT EDIT + +#[tool_router] +impl SuperpositionMcpServer { + #[tool( + name = "context.create", + description = "Creates a new context with specified conditions and overrides." + )] + async fn context_create( + &self, + Parameters(args): Parameters, + ) -> Result { + self.create_context_impl(args).await + } + // ... all other tools ... +} +``` + +### 3. `helpers_gen.rs` — Response Macros + +``` +// AUTO-GENERATED — DO NOT EDIT + +macro_rules! context_to_json { + ($r:expr) => {{ + serde_json::json!({ + "id": $r.id, + "value": $crate::helpers::doc_map_to_json(&$r.value), + // ... derived from output shape fields ... + }) + }} +} +``` + +## Smithy Model → Rust Mapping Rules + +These are deterministic rules the codegen plugin applies: + +### Tool Naming + +``` +Resource "Context" + Operation "Create" → "context.create" +Resource "DefaultConfig" + Operation "List" → "default_config.list" +Resource "ExperimentGroup" + Operation "Delete" → "experiment_group.delete" +``` + +Rule: `snake_case(resource) + "." + snake_case(verb)` + +### Parameter Struct Generation + +For each operation input shape, emit a Rust struct field per member: + +| Smithy Trait | Rust Type | Builder Call Pattern | +|----------------------------|------------------------------|---------------------------------------------| +| `@required` String | `pub field: String` | `.field(args.field)` | +| Optional String | `pub field: Option` | `if let Some(x) = args.field { req = req.field(x); }` | +| `@required` Document/Map | `pub field: serde_json::Value` | `.set_field(Some(json_to_doc_map(args.field)?))` | +| Optional Document/Map | `pub field: Option` | conversion + conditional set | +| `@required` Integer | `pub field: i32` | `.field(args.field)` | +| Optional Integer | `pub field: Option` | conditional | +| `@required` List | `pub field: Vec` | `for x in args.field { req = req.field(x); }` | +| Optional List | `pub field: Option>` | conditional iteration | +| `@httpHeader("x-config-tags")` | `pub config_tags: Option` | `req = req.config_tags(tags);` | +| `@httpQuery("prefix")` | `pub prefix: Option>` | iteration pattern | +| `@httpLabel` | `pub id: String` | `.id(args.id)` | +| `WorkspaceMixin` | (omitted from params) | `.workspace_id(...).org_id(...)` | +| `PaginationParams` | count/page Option | conditional set | + +### Response Macro Generation + +For each output shape, emit a `resource_to_json!` macro. Field mapping: + +| Smithy Output Type | JSON Macro Expression | +|-----------------------------|------------------------------------------------| +| String field | `"field": $r.field` | +| DateTime field | `"field": format_datetime(&$r.field)` | +| HashMap | `"field": doc_map_to_json(&$r.field)` | +| Document field | `"field": doc_to_json(&$r.field)` | +| Integer/Boolean | `"field": $r.field` | +| Optional | `"field": $r.field` (serde handles None→null) | +| Nested structure | `"field": nested_to_json!($r.field)` | + +### Operation Classification + +| Smithy Trait | Behavior | +|-----------------|---------------------------------------------------| +| `@readonly` | GET — no change_reason, no config_tags | +| `@idempotent` | PUT — may have @httpPayload wrapping | +| (default POST) | POST — standard create/update | +| DELETE | DELETE — minimal params (usually just id) | + +### Special Cases (Handled Deterministically) + +1. **`@httpPayload` wrapper**: When input has a `request` field marked + `@httpPayload`, the params struct flattens the payload fields and the impl + constructs the wrapper type via its builder. + +2. **`ContextIdentifier` union**: The `UpdateOverride` operation uses a union + type. The codegen can detect `@union` shapes and emit the appropriate + if/else dispatch. + +3. **Enum fields** (e.g., `ExperimentType`, `SortBy`): Parse string → enum + using `from_str` or match, with validation error on unknown values. + +4. **Bulk operations**: Operations with `List` input get iteration in the + builder call. + +## Implementation Steps + +### Phase 1: Smithy Plugin Skeleton + +1. Create `smithy/mcp-codegen/` as a Gradle/Maven Java project +2. Implement `SmithyIntegration` and `DirectedCodegen` interfaces +3. Register plugin in `smithy-build.json` as `"mcp-rust-codegen"` +4. Walk all operations in the service, group by resource + +### Phase 2: Parameter Struct Generator + +1. For each operation, resolve the effective input shape (expanding mixins) +2. Classify each member by its traits (@httpQuery, @httpHeader, @httpLabel, + @httpPayload, @required, etc.) +3. Filter out WorkspaceMixin/OrganisationMixin members (handled implicitly) +4. Emit `#[derive(Debug, Deserialize, JsonSchema)]` struct with doc comments + from `@documentation` + +### Phase 3: Implementation Generator + +1. Emit the `impl SuperpositionMcpServer` block per resource +2. For each operation, emit the handler method: + - Initialize SDK builder: `self.client.{snake_case(operation_name)}()` + - Always chain `.workspace_id(...)` and `.org_id(...)` (unless org-level op) + - For `@httpPayload` ops: construct the payload type via its builder first + - Chain required fields directly + - Wrap optional fields in `if let Some` + - Apply type conversions (json_to_doc_map for Document types) + - `.send().await.map_err(mcp_err)?` + - Format response using the generated macro + +### Phase 4: Tool Registration Generator + +1. Emit `#[tool_router] impl` block with all `#[tool(...)]` methods +2. Tool name = `snake_case(resource) + "." + snake_case(verb)` +3. Description = `@documentation` from the Smithy operation + +### Phase 5: Response Macro Generator + +1. For each output shape, walk its members +2. Emit `macro_rules!` with field-by-field JSON construction +3. Apply type-specific formatting (DateTime, Document, etc.) + +### Phase 6: Integration + +1. Add to `smithy-build.json` plugins list +2. Wire generated files into `crates/superposition_mcp/src/` via + `include!()` or as a separate generated module +3. Keep `helpers.rs` (json_to_doc, doc_to_json, mcp_err) as hand-written + shared utilities +4. Add CI step: `smithy build` then `cargo check` on MCP crate + +## Alternative: Standalone Rust Generator (Simpler) + +If adding a Smithy Java plugin feels heavyweight, an alternative is a +standalone Rust binary that: + +1. Parses the Smithy JSON AST (run `smithy ast` to get JSON) +2. Walks operations and shapes from the JSON +3. Applies the same deterministic rules above +4. Emits `.rs` files via string templates (e.g., `askama` or `tera`) + +**Pros**: No Java toolchain, can run as `cargo run -p mcp-codegen` +**Cons**: Must parse Smithy's JSON AST format instead of using the Java API + +### Smithy JSON AST Approach + +```bash +# Generate JSON AST from Smithy models +cd smithy && smithy ast models/ > ast.json +``` + +The JSON AST contains all shapes, traits, and relationships needed: + +```json +{ + "smithy": "2.0", + "shapes": { + "io.superposition#CreateContext": { + "type": "operation", + "input": { "target": "io.superposition#CreateContextInput" }, + "output": { "target": "io.superposition#ContextResponse" }, + "traits": { + "smithy.api#http": { "method": "PUT", "uri": "/context" }, + "smithy.api#documentation": "Creates a new context...", + "smithy.api#tags": ["Context Management"] + } + } + } +} +``` + +## File Structure + +``` +superposition/ +├── smithy/ +│ ├── models/ # Existing .smithy files (source of truth) +│ ├── smithy-build.json # Add mcp-codegen plugin +│ └── mcp-codegen/ # Option A: Smithy Java plugin +│ └── src/main/java/ +│ +├── crates/ +│ ├── mcp-codegen/ # Option B: Standalone Rust generator +│ │ ├── Cargo.toml +│ │ ├── src/main.rs # Reads JSON AST, emits .rs files +│ │ └── templates/ # Tera/Askama templates +│ │ +│ └── superposition_mcp/ +│ └── src/ +│ ├── server.rs # Hand-written: McpServer struct, config +│ ├── helpers.rs # Hand-written: json_to_doc, mcp_err, etc. +│ ├── generated/ # AUTO-GENERATED +│ │ ├── mod.rs +│ │ ├── tools/ +│ │ │ ├── context.rs +│ │ │ ├── dimension.rs +│ │ │ └── ... +│ │ ├── server_tools.rs # #[tool_router] impl +│ │ └── response_macros.rs +│ └── tools/ # Optional: hand-written overrides +│ └── mod.rs # Re-exports generated + any custom tools +``` + +## Estimated Complexity + +- **Smithy Plugin (Java)**: ~500-800 lines of Java/Kotlin +- **Standalone Rust Generator**: ~600-1000 lines of Rust +- **Templates**: ~200 lines of template code +- **Integration/CI**: ~50 lines of build config + +The generator would eliminate ~3000+ lines of hand-written boilerplate across +14 tool files and make adding new Smithy operations a zero-code-change process +for the MCP layer. diff --git a/crates/superposition_mcp/Cargo.toml b/crates/superposition_mcp/Cargo.toml new file mode 100644 index 000000000..76c7bdd4c --- /dev/null +++ b/crates/superposition_mcp/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "superposition_mcp" +description = "MCP (Model Context Protocol) server for Superposition, exposing all API operations as MCP tools" +version.workspace = true +edition.workspace = true +rust-version.workspace = true + +[features] +default = [] +actix = ["rmcp-actix-web", "actix-web"] + +[dependencies] +rmcp = { version = "1.2.0", features = ["server", "transport-io"] } +rmcp-actix-web = { version = "0.12", optional = true } +superposition_sdk = { workspace = true } +aws-smithy-types = "1.3.0" +aws-smithy-runtime-api = "1.8.3" +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +actix-web = { workspace = true, optional = true } +schemars = "1" +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +anyhow = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } + +[lints] +workspace = true diff --git a/crates/superposition_mcp/src/actix.rs b/crates/superposition_mcp/src/actix.rs new file mode 100644 index 000000000..423ec3bdc --- /dev/null +++ b/crates/superposition_mcp/src/actix.rs @@ -0,0 +1,48 @@ +//! Actix-web integration for mounting the MCP server as an HTTP endpoint. + +use std::sync::Arc; +use std::time::Duration; + +use actix_web::web; +use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; +use rmcp_actix_web::transport::StreamableHttpService; + +use crate::{McpServerConfig, SuperpositionMcpServer}; + +/// Creates an actix-web `Scope` that serves the MCP server at the mounted path. +/// +/// The returned scope handles POST (JSON-RPC requests), GET (SSE stream resumption), +/// and DELETE (session termination) for the Streamable HTTP MCP transport. +/// +/// # Example +/// +/// ```ignore +/// use actix_web::{App, HttpServer, web}; +/// use superposition_mcp::actix::mcp_scope; +/// +/// let mcp_service = mcp_scope(mcp_config); +/// HttpServer::new(move || { +/// App::new() +/// .service(web::scope("/mcp").service(mcp_service.clone())) +/// }) +/// ``` +pub fn mcp_scope( + config: McpServerConfig, +) -> StreamableHttpService { + StreamableHttpService::builder() + .service_factory(Arc::new(move || { + Ok(SuperpositionMcpServer::new(config.clone())) + })) + .session_manager(Arc::new(LocalSessionManager::default())) + .stateful_mode(true) + .sse_keep_alive(Duration::from_secs(30)) + .build() +} + +/// Creates an actix-web `Scope` mounted at `/mcp`. +/// +/// This is a convenience wrapper that creates the scope with the standard path. +pub fn mcp_service(config: McpServerConfig) -> actix_web::Scope { + let service = mcp_scope(config); + web::scope("/mcp").service(service.scope()) +} diff --git a/crates/superposition_mcp/src/config.rs b/crates/superposition_mcp/src/config.rs new file mode 100644 index 000000000..c0a433f01 --- /dev/null +++ b/crates/superposition_mcp/src/config.rs @@ -0,0 +1,54 @@ +use std::env; + +/// Configuration for the Superposition MCP server. +#[derive(Clone, Debug)] +pub struct McpServerConfig { + /// Base URL of the Superposition API (e.g., "http://localhost:8080") + pub endpoint_url: String, + /// Default workspace ID injected into all SDK calls + pub workspace_id: String, + /// Default organisation ID injected into all SDK calls + pub org_id: String, + /// Optional bearer token for authentication + pub auth_token: Option, +} + +impl McpServerConfig { + /// Reads configuration from environment variables. + /// + /// Required: + /// - `SUPERPOSITION_URL` — Base URL of the Superposition API + /// - `SUPERPOSITION_WORKSPACE` — Default workspace ID + /// - `SUPERPOSITION_ORG_ID` — Default organisation ID + /// + /// Optional: + /// - `SUPERPOSITION_AUTH_TOKEN` — Bearer token for authentication + pub fn from_env() -> anyhow::Result { + Ok(Self { + endpoint_url: env::var("SUPERPOSITION_URL") + .map_err(|_| anyhow::anyhow!("SUPERPOSITION_URL env var is required"))?, + workspace_id: env::var("SUPERPOSITION_WORKSPACE").map_err(|_| { + anyhow::anyhow!("SUPERPOSITION_WORKSPACE env var is required") + })?, + org_id: env::var("SUPERPOSITION_ORG_ID").map_err(|_| { + anyhow::anyhow!("SUPERPOSITION_ORG_ID env var is required") + })?, + auth_token: env::var("SUPERPOSITION_AUTH_TOKEN").ok(), + }) + } + + /// Build a `superposition_sdk::Client` from this configuration. + pub fn build_sdk_client(&self) -> superposition_sdk::Client { + let mut config_builder = superposition_sdk::Config::builder() + .endpoint_url(&self.endpoint_url) + .behavior_version(superposition_sdk::config::BehaviorVersion::latest()); + + if let Some(ref token) = self.auth_token { + use aws_smithy_runtime_api::client::identity::http::Token; + config_builder = config_builder.bearer_token(Token::new(token, None)); + } + + let config = config_builder.build(); + superposition_sdk::Client::from_conf(config) + } +} diff --git a/crates/superposition_mcp/src/generated/audit_log.rs b/crates/superposition_mcp/src/generated/audit_log.rs new file mode 100644 index 000000000..c48e344f6 --- /dev/null +++ b/crates/superposition_mcp/src/generated/audit_log.rs @@ -0,0 +1,73 @@ +// AUTO-GENERATED by smithy mcp-codegen — DO NOT EDIT +use rmcp::model::*; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::SuperpositionMcpServer; +use crate::helpers::*; + +/// Retrieves a paginated list of audit logs with support for filtering by date range, table names, actions, and usernames for compliance and monitoring purposes. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListAuditLogParams { + /// Number of items to be returned in each page. + pub count: Option, + /// Page number to retrieve, starting from 1. + pub page: Option, + /// If true, returns all requested items, ignoring pagination parameters page and count. + pub all: Option, + pub from_date: Option, + pub to_date: Option, + pub tables: Option>, + pub action: Option>, + pub username: Option, + pub sort_by: Option, +} + +impl SuperpositionMcpServer { + pub async fn list_audit_log_impl(&self, args: ListAuditLogParams) -> Result { + let mut req = self.client.list_audit_logs() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + if let Some(v) = args.count { + req = req.count(v); + } + if let Some(v) = args.page { + req = req.page(v); + } + if let Some(v) = args.all { + req = req.all(v); + } + if let Some(v) = args.from_date { + req = req.from_date(v); + } + if let Some(v) = args.to_date { + req = req.to_date(v); + } + if let Some(v) = args.tables { + for item in v { + req = req.tables(item); + } + } + if let Some(v) = args.action { + for item in v { + req = req.action(item); + } + } + if let Some(v) = args.username { + req = req.username(v); + } + if let Some(v) = args.sort_by { + req = req.sort_by(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let items: Vec = resp.data.iter().map(|r| audit_log_to_json!(r)).collect(); + let result = serde_json::json!({ + "total_pages": resp.total_pages, + "total_items": resp.total_items, + "data": items, + }); + let json = serde_json::to_string_pretty(&result).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + +} diff --git a/crates/superposition_mcp/src/generated/config.rs b/crates/superposition_mcp/src/generated/config.rs new file mode 100644 index 000000000..b7300b5dd --- /dev/null +++ b/crates/superposition_mcp/src/generated/config.rs @@ -0,0 +1,159 @@ +// AUTO-GENERATED by smithy mcp-codegen — DO NOT EDIT +use rmcp::model::*; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::SuperpositionMcpServer; +use crate::helpers::*; + +/// Retrieves config data with context evaluation, including applicable contexts, overrides, and default values based on provided conditions. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetConfigParams { + pub prefix: Option>, + pub version: Option, + pub context: Option, +} + +/// Resolves and merges config values based on context conditions, applying overrides and merge strategies to produce the final configuration. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetResolvedConfigConfigParams { + pub prefix: Option>, + pub version: Option, + pub show_reasoning: Option, + pub merge_strategy: Option, + pub context_id: Option, + /// Intended for control resolution. If true, evaluates and includes remote cohort-based contexts during config resolution. + pub resolve_remote: Option, + pub context: Option, +} + +/// Resolves and merges config values based on context conditions and identifier, applying overrides and merge strategies to produce the final configuration. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetResolvedConfigWithIdentifierConfigParams { + pub prefix: Option>, + pub version: Option, + pub show_reasoning: Option, + pub merge_strategy: Option, + pub context_id: Option, + /// Intended for control resolution. If true, evaluates and includes remote cohort-based contexts during config resolution. + pub resolve_remote: Option, + pub context: Option, + pub identifier: Option, +} + +impl SuperpositionMcpServer { + pub async fn get_config_fast_config_impl(&self) -> Result { + let mut req = self.client.get_config_fast() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&config_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn get_config_impl(&self, args: GetConfigParams) -> Result { + let mut req = self.client.get_config() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + if let Some(v) = args.prefix { + for item in v { + req = req.prefix(item); + } + } + if let Some(v) = args.version { + req = req.version(v); + } + if let Some(v) = args.context { + req = req.set_context(Some(json_to_doc_map(v).map_err(mcp_err)?)); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&config_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn get_resolved_config_config_impl(&self, args: GetResolvedConfigConfigParams) -> Result { + let mut req = self.client.get_resolved_config() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + if let Some(v) = args.prefix { + for item in v { + req = req.prefix(item); + } + } + if let Some(v) = args.version { + req = req.version(v); + } + if let Some(v) = args.show_reasoning { + req = req.show_reasoning(v); + } + if let Some(v) = args.context_id { + req = req.context_id(v); + } + if let Some(v) = args.resolve_remote { + req = req.resolve_remote(v); + } + if let Some(v) = args.context { + req = req.set_context(Some(json_to_doc_map(v).map_err(mcp_err)?)); + } + if let Some(v) = args.merge_strategy { + req = req.merge_strategy(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&config_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn get_resolved_config_with_identifier_config_impl(&self, args: GetResolvedConfigWithIdentifierConfigParams) -> Result { + let mut req = self.client.get_resolved_config_with_identifier() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + if let Some(v) = args.prefix { + for item in v { + req = req.prefix(item); + } + } + if let Some(v) = args.version { + req = req.version(v); + } + if let Some(v) = args.show_reasoning { + req = req.show_reasoning(v); + } + if let Some(v) = args.context_id { + req = req.context_id(v); + } + if let Some(v) = args.resolve_remote { + req = req.resolve_remote(v); + } + if let Some(v) = args.context { + req = req.set_context(Some(json_to_doc_map(v).map_err(mcp_err)?)); + } + if let Some(v) = args.identifier { + req = req.identifier(v); + } + if let Some(v) = args.merge_strategy { + req = req.merge_strategy(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&config_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn get_config_toml_config_impl(&self) -> Result { + let mut req = self.client.get_config_toml() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&config_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn get_config_json_config_impl(&self) -> Result { + let mut req = self.client.get_config_json() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&config_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + +} diff --git a/crates/superposition_mcp/src/generated/config_version.rs b/crates/superposition_mcp/src/generated/config_version.rs new file mode 100644 index 000000000..b800e917c --- /dev/null +++ b/crates/superposition_mcp/src/generated/config_version.rs @@ -0,0 +1,56 @@ +// AUTO-GENERATED by smithy mcp-codegen — DO NOT EDIT +use rmcp::model::*; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::SuperpositionMcpServer; +use crate::helpers::*; + +/// Retrieves a specific config version along with its metadata for audit and rollback purposes. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetConfigVersionParams { + pub id: String, +} + +/// Retrieves a paginated list of config versions with their metadata, hash values, and creation timestamps for audit and rollback purposes. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListConfigVersionParams { + /// Number of items to be returned in each page. + pub count: Option, + /// Page number to retrieve, starting from 1. + pub page: Option, +} + +impl SuperpositionMcpServer { + pub async fn get_config_version_impl(&self, args: GetConfigVersionParams) -> Result { + let mut req = self.client.get_version() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.id(args.id); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&config_version_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn list_config_version_impl(&self, args: ListConfigVersionParams) -> Result { + let mut req = self.client.list_versions() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + if let Some(v) = args.count { + req = req.count(v); + } + if let Some(v) = args.page { + req = req.page(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let items: Vec = resp.data.iter().map(|r| config_version_to_json!(r)).collect(); + let result = serde_json::json!({ + "total_pages": resp.total_pages, + "total_items": resp.total_items, + "data": items, + }); + let json = serde_json::to_string_pretty(&result).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + +} diff --git a/crates/superposition_mcp/src/generated/context.rs b/crates/superposition_mcp/src/generated/context.rs new file mode 100644 index 000000000..feaca89e3 --- /dev/null +++ b/crates/superposition_mcp/src/generated/context.rs @@ -0,0 +1,258 @@ +// AUTO-GENERATED by smithy mcp-codegen — DO NOT EDIT +use rmcp::model::*; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::SuperpositionMcpServer; +use crate::helpers::*; + +/// Creates a new context with specified conditions and overrides. Contexts define conditional rules for config management. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateContextParams { + pub config_tags: Option, + pub context: serde_json::Value, + pub r#override: serde_json::Value, + pub description: Option, + pub change_reason: String, +} + +/// Retrieves detailed information about a specific context by its unique identifier, including conditions, overrides, and metadata. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetContextParams { + pub id: String, +} + +/// Permanently removes a context from the workspace. This operation cannot be undone and will affect config resolution. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DeleteContextParams { + pub id: String, + pub config_tags: Option, +} + +/// Retrieves a paginated list of contexts with support for filtering by creation date, modification date, weight, and other criteria. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListContextParams { + /// Number of items to be returned in each page. + pub count: Option, + /// Page number to retrieve, starting from 1. + pub page: Option, + /// If true, returns all requested items, ignoring pagination parameters page and count. + pub all: Option, + pub prefix: Option>, + pub sort_on: Option, + pub sort_by: Option, + pub created_by: Option>, + pub last_modified_by: Option>, + pub plaintext: Option, + pub dimension_match_strategy: Option, +} + +/// Updates the condition of the mentioned context, if a context with the new condition already exists, it merges the override and effectively deleting the old context +#[derive(Debug, Deserialize, JsonSchema)] +pub struct MoveContextParams { + pub id: String, + pub context: serde_json::Value, + pub description: Option, + pub change_reason: String, +} + +/// Updates the overrides for an existing context. Allows modification of override values while maintaining the context's conditions. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct UpdateOverrideContextParams { + pub config_tags: Option, + pub context: serde_json::Value, + pub r#override: serde_json::Value, + pub description: Option, + pub change_reason: String, +} + +/// Recalculates and updates the priority weights for all contexts in the workspace based on their dimensions. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct WeightRecomputeContextParams { + pub config_tags: Option, +} + +/// Executes multiple context operations (PUT, REPLACE, DELETE, MOVE) in a single atomic transaction for efficient batch processing. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct BulkOperationContextParams { + pub config_tags: Option, + pub operations: Vec, +} + +/// Validates if a given context condition is well-formed +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ValidateContextParams { + pub context: serde_json::Value, +} + +impl SuperpositionMcpServer { + pub async fn create_context_impl(&self, args: CreateContextParams) -> Result { + let mut req = self.client.create_context() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.set_context(Some(json_to_doc_map(args.context).map_err(mcp_err)?)); + req = req.set_override(Some(json_to_doc_map(args.r#override).map_err(mcp_err)?)); + req = req.change_reason(args.change_reason); + if let Some(v) = args.description { + req = req.description(v); + } + if let Some(v) = args.config_tags { + req = req.config_tags(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&context_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn get_context_impl(&self, args: GetContextParams) -> Result { + let mut req = self.client.get_context() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.id(args.id); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&context_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn delete_context_impl(&self, args: DeleteContextParams) -> Result { + let mut req = self.client.delete_context() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.id(args.id); + if let Some(v) = args.config_tags { + req = req.config_tags(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = "Deleted successfully".to_string(); + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn list_context_impl(&self, args: ListContextParams) -> Result { + let mut req = self.client.list_contexts() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + if let Some(v) = args.count { + req = req.count(v); + } + if let Some(v) = args.page { + req = req.page(v); + } + if let Some(v) = args.all { + req = req.all(v); + } + if let Some(v) = args.prefix { + for item in v { + req = req.prefix(item); + } + } + if let Some(v) = args.sort_on { + req = req.sort_on(v); + } + if let Some(v) = args.sort_by { + req = req.sort_by(v); + } + if let Some(v) = args.created_by { + for item in v { + req = req.created_by(item); + } + } + if let Some(v) = args.last_modified_by { + for item in v { + req = req.last_modified_by(item); + } + } + if let Some(v) = args.plaintext { + req = req.plaintext(v); + } + if let Some(v) = args.dimension_match_strategy { + req = req.dimension_match_strategy(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let items: Vec = resp.data.iter().map(|r| context_to_json!(r)).collect(); + let result = serde_json::json!({ + "total_pages": resp.total_pages, + "total_items": resp.total_items, + "data": items, + }); + let json = serde_json::to_string_pretty(&result).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn move_context_impl(&self, args: MoveContextParams) -> Result { + let mut req = self.client.move_context() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.id(args.id); + req = req.set_context(Some(json_to_doc_map(args.context).map_err(mcp_err)?)); + req = req.change_reason(args.change_reason); + if let Some(v) = args.description { + req = req.description(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&context_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn update_override_context_impl(&self, args: UpdateOverrideContextParams) -> Result { + let mut req = self.client.update_override() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.context(args.context); + req = req.set_override(Some(json_to_doc_map(args.r#override).map_err(mcp_err)?)); + req = req.change_reason(args.change_reason); + if let Some(v) = args.description { + req = req.description(v); + } + if let Some(v) = args.config_tags { + req = req.config_tags(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&context_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn get_context_from_condition_context_impl(&self) -> Result { + let mut req = self.client.get_context_from_condition() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&context_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn weight_recompute_context_impl(&self, args: WeightRecomputeContextParams) -> Result { + let mut req = self.client.weight_recompute() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + if let Some(v) = args.config_tags { + req = req.config_tags(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&context_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn bulk_operation_context_impl(&self, args: BulkOperationContextParams) -> Result { + let mut req = self.client.bulk_operation() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.operations(args.operations); + if let Some(v) = args.config_tags { + req = req.config_tags(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&context_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn validate_context_impl(&self, args: ValidateContextParams) -> Result { + let mut req = self.client.validate_context() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.set_context(Some(json_to_doc_map(args.context).map_err(mcp_err)?)); + let resp = req.send().await.map_err(mcp_err)?; + let json = "Success".to_string(); + Ok(CallToolResult::success(vec![Content::text(json)])) + } + +} diff --git a/crates/superposition_mcp/src/generated/default_config.rs b/crates/superposition_mcp/src/generated/default_config.rs new file mode 100644 index 000000000..c40dc4e1d --- /dev/null +++ b/crates/superposition_mcp/src/generated/default_config.rs @@ -0,0 +1,153 @@ +// AUTO-GENERATED by smithy mcp-codegen — DO NOT EDIT +use rmcp::model::*; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::SuperpositionMcpServer; +use crate::helpers::*; + +/// Retrieves a specific default config entry by its key, including its value, schema, function mappings, and metadata. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetDefaultConfigParams { + pub key: String, +} + +/// Updates an existing default config entry. Allows modification of value, schema, function mappings, and description while preserving the key identifier. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct UpdateDefaultConfigParams { + pub key: String, + pub change_reason: String, + pub value: Option, + pub schema: Option, + /// To unset the function name, pass "null" string. + pub value_validation_function_name: Option, + pub description: Option, + /// To unset the function name, pass "null" string. + pub value_compute_function_name: Option, +} + +/// Permanently removes a default config entry from the workspace. This operation cannot be performed if it affects config resolution for contexts that rely on this fallback value. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DeleteDefaultConfigParams { + pub key: String, +} + +/// Retrieves a paginated list of all default config entries in the workspace, including their values, schemas, and metadata. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListDefaultConfigParams { + /// Number of items to be returned in each page. + pub count: Option, + /// Page number to retrieve, starting from 1. + pub page: Option, + /// If true, returns all requested items, ignoring pagination parameters page and count. + pub all: Option, + pub name: Option, +} + +/// Creates a new default config entry with specified key, value, schema, and metadata. Default configs serve as fallback values when no specific context matches. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateDefaultConfigParams { + pub key: String, + pub value: serde_json::Value, + pub schema: serde_json::Value, + pub description: String, + pub change_reason: String, + pub value_validation_function_name: Option, + pub value_compute_function_name: Option, +} + +impl SuperpositionMcpServer { + pub async fn get_default_config_impl(&self, args: GetDefaultConfigParams) -> Result { + let mut req = self.client.get_default_config() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.key(args.key); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&default_config_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn update_default_config_impl(&self, args: UpdateDefaultConfigParams) -> Result { + let mut req = self.client.update_default_config() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.key(args.key); + req = req.change_reason(args.change_reason); + if let Some(v) = args.value { + req = req.value(json_to_doc(v)); + } + if let Some(v) = args.schema { + req = req.set_schema(Some(json_to_doc_map(v).map_err(mcp_err)?)); + } + if let Some(v) = args.value_validation_function_name { + req = req.value_validation_function_name(v); + } + if let Some(v) = args.description { + req = req.description(v); + } + if let Some(v) = args.value_compute_function_name { + req = req.value_compute_function_name(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&default_config_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn delete_default_config_impl(&self, args: DeleteDefaultConfigParams) -> Result { + let mut req = self.client.delete_default_config() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.key(args.key); + let resp = req.send().await.map_err(mcp_err)?; + let json = "Deleted successfully".to_string(); + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn list_default_config_impl(&self, args: ListDefaultConfigParams) -> Result { + let mut req = self.client.list_default_configs() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + if let Some(v) = args.count { + req = req.count(v); + } + if let Some(v) = args.page { + req = req.page(v); + } + if let Some(v) = args.all { + req = req.all(v); + } + if let Some(v) = args.name { + req = req.name(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let items: Vec = resp.data.iter().map(|r| default_config_to_json!(r)).collect(); + let result = serde_json::json!({ + "total_pages": resp.total_pages, + "total_items": resp.total_items, + "data": items, + }); + let json = serde_json::to_string_pretty(&result).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn create_default_config_impl(&self, args: CreateDefaultConfigParams) -> Result { + let mut req = self.client.create_default_config() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.key(args.key); + req = req.value(json_to_doc(args.value)); + req = req.set_schema(Some(json_to_doc_map(args.schema).map_err(mcp_err)?)); + req = req.description(args.description); + req = req.change_reason(args.change_reason); + if let Some(v) = args.value_validation_function_name { + req = req.value_validation_function_name(v); + } + if let Some(v) = args.value_compute_function_name { + req = req.value_compute_function_name(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&default_config_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + +} diff --git a/crates/superposition_mcp/src/generated/dimension.rs b/crates/superposition_mcp/src/generated/dimension.rs new file mode 100644 index 000000000..db5bbe0d8 --- /dev/null +++ b/crates/superposition_mcp/src/generated/dimension.rs @@ -0,0 +1,153 @@ +// AUTO-GENERATED by smithy mcp-codegen — DO NOT EDIT +use rmcp::model::*; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::SuperpositionMcpServer; +use crate::helpers::*; + +/// Retrieves detailed information about a specific dimension, including its schema, cohort dependency graph, and configuration metadata. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetDimensionParams { + pub dimension: String, +} + +/// Updates an existing dimension's configuration. Allows modification of schema, position, function mappings, and other properties while maintaining dependency relationships. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct UpdateDimensionParams { + pub dimension: String, + pub schema: Option, + pub position: Option, + /// To unset the function name, pass "null" string. + pub value_validation_function_name: Option, + pub description: Option, + pub change_reason: String, + /// To unset the function name, pass "null" string. + pub value_compute_function_name: Option, +} + +/// Permanently removes a dimension from the workspace. This operation will fail if the dimension has active dependencies or is referenced by existing configurations. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DeleteDimensionParams { + pub dimension: String, +} + +/// Retrieves a paginated list of all dimensions in the workspace. Dimensions are returned with their details and metadata. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListDimensionParams { + /// Number of items to be returned in each page. + pub count: Option, + /// Page number to retrieve, starting from 1. + pub page: Option, + /// If true, returns all requested items, ignoring pagination parameters page and count. + pub all: Option, +} + +/// Creates a new dimension with the specified json schema. Dimensions define categorical attributes used for context-based config management. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateDimensionParams { + pub dimension: String, + pub position: i32, + pub schema: serde_json::Value, + pub value_validation_function_name: Option, + pub description: String, + pub change_reason: String, + pub dimension_type: Option, + pub value_compute_function_name: Option, +} + +impl SuperpositionMcpServer { + pub async fn get_dimension_impl(&self, args: GetDimensionParams) -> Result { + let mut req = self.client.get_dimension() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.dimension(args.dimension); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&dimension_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn update_dimension_impl(&self, args: UpdateDimensionParams) -> Result { + let mut req = self.client.update_dimension() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.dimension(args.dimension); + req = req.change_reason(args.change_reason); + if let Some(v) = args.schema { + req = req.set_schema(Some(json_to_doc_map(v).map_err(mcp_err)?)); + } + if let Some(v) = args.position { + req = req.position(v); + } + if let Some(v) = args.value_validation_function_name { + req = req.value_validation_function_name(v); + } + if let Some(v) = args.description { + req = req.description(v); + } + if let Some(v) = args.value_compute_function_name { + req = req.value_compute_function_name(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&dimension_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn delete_dimension_impl(&self, args: DeleteDimensionParams) -> Result { + let mut req = self.client.delete_dimension() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.dimension(args.dimension); + let resp = req.send().await.map_err(mcp_err)?; + let json = "Deleted successfully".to_string(); + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn list_dimension_impl(&self, args: ListDimensionParams) -> Result { + let mut req = self.client.list_dimensions() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + if let Some(v) = args.count { + req = req.count(v); + } + if let Some(v) = args.page { + req = req.page(v); + } + if let Some(v) = args.all { + req = req.all(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let items: Vec = resp.data.iter().map(|r| dimension_to_json!(r)).collect(); + let result = serde_json::json!({ + "total_pages": resp.total_pages, + "total_items": resp.total_items, + "data": items, + }); + let json = serde_json::to_string_pretty(&result).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn create_dimension_impl(&self, args: CreateDimensionParams) -> Result { + let mut req = self.client.create_dimension() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.dimension(args.dimension); + req = req.position(args.position); + req = req.set_schema(Some(json_to_doc_map(args.schema).map_err(mcp_err)?)); + req = req.description(args.description); + req = req.change_reason(args.change_reason); + if let Some(v) = args.value_validation_function_name { + req = req.value_validation_function_name(v); + } + if let Some(v) = args.dimension_type { + req = req.dimension_type(v); + } + if let Some(v) = args.value_compute_function_name { + req = req.value_compute_function_name(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&dimension_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + +} diff --git a/crates/superposition_mcp/src/generated/experiment_group.rs b/crates/superposition_mcp/src/generated/experiment_group.rs new file mode 100644 index 000000000..c43edbbdc --- /dev/null +++ b/crates/superposition_mcp/src/generated/experiment_group.rs @@ -0,0 +1,218 @@ +// AUTO-GENERATED by smithy mcp-codegen — DO NOT EDIT +use rmcp::model::*; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::SuperpositionMcpServer; +use crate::helpers::*; + +/// Creates a new experiment group. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateExperimentGroupParams { + pub name: String, + pub description: String, + /// Reason for creating this experiment group. + pub change_reason: String, + pub context: serde_json::Value, + pub traffic_percentage: i32, + /// List of experiment IDs that are members of this group. + pub member_experiment_ids: Option>, +} + +/// Retrieves an existing experiment group by its ID. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetExperimentGroupParams { + pub id: String, +} + +/// Updates an existing experiment group. Allows partial updates to specified fields. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct UpdateExperimentGroupParams { + pub id: String, + /// Reason for this update. + pub change_reason: String, + /// Optional new description for the group. + pub description: Option, + /// Optional new traffic percentage for the group. + pub traffic_percentage: Option, +} + +/// Deletes an experiment group. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DeleteExperimentGroupParams { + pub id: String, +} + +/// Lists experiment groups, with support for filtering and pagination. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListExperimentGroupParams { + /// Number of items to be returned in each page. + pub count: Option, + /// Page number to retrieve, starting from 1. + pub page: Option, + /// If true, returns all requested items, ignoring pagination parameters page and count. + pub all: Option, + /// Filter by experiment group name (exact match or substring, depending on backend implementation). + pub name: Option, + /// Filter by the user who created the experiment group. + pub created_by: Option, + /// Filter by the user who last modified the experiment group. + pub last_modified_by: Option, + /// Field to sort the results by. + pub sort_on: Option, + /// Sort order (ascending or descending). + pub sort_by: Option, + /// Filter by the type of group (USER_CREATED or SYSTEM_GENERATED). + pub group_type: Option>, +} + +/// Adds members to an existing experiment group. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct AddMembersToGroupExperimentGroupParams { + pub id: String, + /// Reason for adding these members. + pub change_reason: String, + /// List of experiment IDs to add/remove to this group. + pub member_experiment_ids: Vec, +} + +/// Removes members from an existing experiment group. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct RemoveMembersFromGroupExperimentGroupParams { + pub id: String, + /// Reason for adding these members. + pub change_reason: String, + /// List of experiment IDs to add/remove to this group. + pub member_experiment_ids: Vec, +} + +impl SuperpositionMcpServer { + pub async fn create_experiment_group_impl(&self, args: CreateExperimentGroupParams) -> Result { + let mut req = self.client.create_experiment_group() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.name(args.name); + req = req.description(args.description); + req = req.change_reason(args.change_reason); + req = req.set_context(Some(json_to_doc_map(args.context).map_err(mcp_err)?)); + req = req.traffic_percentage(args.traffic_percentage); + if let Some(v) = args.member_experiment_ids { + for item in v { + req = req.member_experiment_ids(item); + } + } + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&experiment_group_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn get_experiment_group_impl(&self, args: GetExperimentGroupParams) -> Result { + let mut req = self.client.get_experiment_group() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.id(args.id); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&experiment_group_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn update_experiment_group_impl(&self, args: UpdateExperimentGroupParams) -> Result { + let mut req = self.client.update_experiment_group() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.id(args.id); + req = req.change_reason(args.change_reason); + if let Some(v) = args.description { + req = req.description(v); + } + if let Some(v) = args.traffic_percentage { + req = req.traffic_percentage(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&experiment_group_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn delete_experiment_group_impl(&self, args: DeleteExperimentGroupParams) -> Result { + let mut req = self.client.delete_experiment_group() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.id(args.id); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&experiment_group_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn list_experiment_group_impl(&self, args: ListExperimentGroupParams) -> Result { + let mut req = self.client.list_experiment_groups() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + if let Some(v) = args.count { + req = req.count(v); + } + if let Some(v) = args.page { + req = req.page(v); + } + if let Some(v) = args.all { + req = req.all(v); + } + if let Some(v) = args.name { + req = req.name(v); + } + if let Some(v) = args.created_by { + req = req.created_by(v); + } + if let Some(v) = args.last_modified_by { + req = req.last_modified_by(v); + } + if let Some(v) = args.sort_on { + req = req.sort_on(v); + } + if let Some(v) = args.sort_by { + req = req.sort_by(v); + } + if let Some(v) = args.group_type { + for item in v { + req = req.group_type(item); + } + } + let resp = req.send().await.map_err(mcp_err)?; + let items: Vec = resp.data.iter().map(|r| experiment_group_to_json!(r)).collect(); + let result = serde_json::json!({ + "total_pages": resp.total_pages, + "total_items": resp.total_items, + "data": items, + }); + let json = serde_json::to_string_pretty(&result).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn add_members_to_group_experiment_group_impl(&self, args: AddMembersToGroupExperimentGroupParams) -> Result { + let mut req = self.client.add_members_to_group() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.id(args.id); + req = req.change_reason(args.change_reason); + for item in args.member_experiment_ids { + req = req.member_experiment_ids(item); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&experiment_group_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn remove_members_from_group_experiment_group_impl(&self, args: RemoveMembersFromGroupExperimentGroupParams) -> Result { + let mut req = self.client.remove_members_from_group() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.id(args.id); + req = req.change_reason(args.change_reason); + for item in args.member_experiment_ids { + req = req.member_experiment_ids(item); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&experiment_group_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + +} diff --git a/crates/superposition_mcp/src/generated/experiments.rs b/crates/superposition_mcp/src/generated/experiments.rs new file mode 100644 index 000000000..03787f08c --- /dev/null +++ b/crates/superposition_mcp/src/generated/experiments.rs @@ -0,0 +1,313 @@ +// AUTO-GENERATED by smithy mcp-codegen — DO NOT EDIT +use rmcp::model::*; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::SuperpositionMcpServer; +use crate::helpers::*; + +/// Creates a new experiment with variants, context and conditions. You can optionally specify metrics and experiment group for tracking and analysis. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateExperimentsParams { + pub name: String, + pub experiment_type: Option, + pub context: serde_json::Value, + pub variants: Vec, + pub description: String, + pub change_reason: String, + pub metrics: Option, + pub experiment_group_id: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateExperimentsVariantsItem { + pub id: String, + pub variant_type: String, + pub context_id: Option, + pub override_id: Option, + pub overrides: serde_json::Value, +} + +/// Retrieves detailed information about a specific experiment, including its config, variants, status, and metrics. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetExperimentsParams { + pub id: String, +} + +/// Retrieves a paginated list of experiments with support for filtering by status, date range, name, creator, and experiment group. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListExperimentsParams { + /// Number of items to be returned in each page. + pub count: Option, + /// Page number to retrieve, starting from 1. + pub page: Option, + /// If true, returns all requested items, ignoring pagination parameters page and count. + pub all: Option, + pub status: Option>, + pub from_date: Option, + pub to_date: Option, + pub experiment_name: Option, + pub experiment_ids: Option>, + pub experiment_group_ids: Option>, + pub created_by: Option>, + pub sort_on: Option, + pub sort_by: Option, + pub global_experiments_only: Option, + pub dimension_match_strategy: Option, +} + +/// Updates the overrides for specific variants within an experiment, allowing modification of experiment behavior Updates the overrides for specific variants within an experiment, allowing modification of experiment behavior while it is in the created state. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct UpdateOverridesExperimentExperimentsParams { + pub id: String, + pub variant_list: Vec, + pub description: Option, + pub change_reason: String, + pub metrics: Option, + /// To unset experiment group, pass "null" string. + pub experiment_group_id: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct UpdateOverridesExperimentExperimentsVariantListItem { + pub id: String, + pub overrides: serde_json::Value, +} + +/// Concludes an inprogress experiment by selecting a winning variant and transitioning the experiment to a concluded state. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ConcludeExperimentExperimentsParams { + pub id: String, + pub chosen_variant: String, + pub description: Option, + pub change_reason: String, +} + +/// Discards an experiment without selecting a winner, effectively canceling the experiment and removing its effects. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DiscardExperimentExperimentsParams { + pub id: String, + pub change_reason: String, +} + +/// Adjusts the traffic percentage allocation for an in-progress experiment, allowing gradual rollout or rollback of experimental features. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct RampExperimentExperimentsParams { + pub id: String, + pub change_reason: String, + pub traffic_percentage: i32, +} + +/// Temporarily pauses an inprogress experiment, suspending its effects while preserving the experiment config for later resumption. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct PauseExperimentExperimentsParams { + pub id: String, + pub change_reason: String, +} + +/// Resumes a previously paused experiment, restoring its in-progress state and re-enabling variant evaluation. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ResumeExperimentExperimentsParams { + pub id: String, + pub change_reason: String, +} + +/// Determines which experiment variants are applicable to a given context, used for experiment evaluation and variant selection. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ApplicableVariantsExperimentsParams { + pub context: serde_json::Value, + pub identifier: String, +} + +impl SuperpositionMcpServer { + pub async fn create_experiments_impl(&self, args: CreateExperimentsParams) -> Result { + let mut req = self.client.create_experiment() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.name(args.name); + req = req.set_context(Some(json_to_doc_map(args.context).map_err(mcp_err)?)); + req = req.variants(args.variants); + req = req.description(args.description); + req = req.change_reason(args.change_reason); + if let Some(v) = args.experiment_type { + req = req.experiment_type(v); + } + if let Some(v) = args.metrics { + req = req.metrics(json_to_doc(v)); + } + if let Some(v) = args.experiment_group_id { + req = req.experiment_group_id(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&experiments_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn get_experiments_impl(&self, args: GetExperimentsParams) -> Result { + let mut req = self.client.get_experiment() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.id(args.id); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&experiments_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn list_experiments_impl(&self, args: ListExperimentsParams) -> Result { + let mut req = self.client.list_experiment() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + if let Some(v) = args.count { + req = req.count(v); + } + if let Some(v) = args.page { + req = req.page(v); + } + if let Some(v) = args.all { + req = req.all(v); + } + if let Some(v) = args.status { + for item in v { + req = req.status(item); + } + } + if let Some(v) = args.from_date { + req = req.from_date(v); + } + if let Some(v) = args.to_date { + req = req.to_date(v); + } + if let Some(v) = args.experiment_name { + req = req.experiment_name(v); + } + if let Some(v) = args.experiment_ids { + for item in v { + req = req.experiment_ids(item); + } + } + if let Some(v) = args.experiment_group_ids { + for item in v { + req = req.experiment_group_ids(item); + } + } + if let Some(v) = args.created_by { + for item in v { + req = req.created_by(item); + } + } + if let Some(v) = args.sort_on { + req = req.sort_on(v); + } + if let Some(v) = args.sort_by { + req = req.sort_by(v); + } + if let Some(v) = args.global_experiments_only { + req = req.global_experiments_only(v); + } + if let Some(v) = args.dimension_match_strategy { + req = req.dimension_match_strategy(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let items: Vec = resp.data.iter().map(|r| experiments_to_json!(r)).collect(); + let result = serde_json::json!({ + "total_pages": resp.total_pages, + "total_items": resp.total_items, + "data": items, + }); + let json = serde_json::to_string_pretty(&result).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn update_overrides_experiment_experiments_impl(&self, args: UpdateOverridesExperimentExperimentsParams) -> Result { + let mut req = self.client.update_overrides_experiment() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.id(args.id); + req = req.variant_list(args.variant_list); + req = req.change_reason(args.change_reason); + if let Some(v) = args.description { + req = req.description(v); + } + if let Some(v) = args.metrics { + req = req.metrics(json_to_doc(v)); + } + if let Some(v) = args.experiment_group_id { + req = req.experiment_group_id(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&experiments_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn conclude_experiment_experiments_impl(&self, args: ConcludeExperimentExperimentsParams) -> Result { + let mut req = self.client.conclude_experiment() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.id(args.id); + req = req.chosen_variant(args.chosen_variant); + req = req.change_reason(args.change_reason); + if let Some(v) = args.description { + req = req.description(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&experiments_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn discard_experiment_experiments_impl(&self, args: DiscardExperimentExperimentsParams) -> Result { + let mut req = self.client.discard_experiment() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.id(args.id); + req = req.change_reason(args.change_reason); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&experiments_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn ramp_experiment_experiments_impl(&self, args: RampExperimentExperimentsParams) -> Result { + let mut req = self.client.ramp_experiment() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.id(args.id); + req = req.change_reason(args.change_reason); + req = req.traffic_percentage(args.traffic_percentage); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&experiments_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn pause_experiment_experiments_impl(&self, args: PauseExperimentExperimentsParams) -> Result { + let mut req = self.client.pause_experiment() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.id(args.id); + req = req.change_reason(args.change_reason); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&experiments_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn resume_experiment_experiments_impl(&self, args: ResumeExperimentExperimentsParams) -> Result { + let mut req = self.client.resume_experiment() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.id(args.id); + req = req.change_reason(args.change_reason); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&experiments_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn applicable_variants_experiments_impl(&self, args: ApplicableVariantsExperimentsParams) -> Result { + let mut req = self.client.applicable_variants() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.set_context(Some(json_to_doc_map(args.context).map_err(mcp_err)?)); + req = req.identifier(args.identifier); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&experiments_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + +} diff --git a/crates/superposition_mcp/src/generated/function.rs b/crates/superposition_mcp/src/generated/function.rs new file mode 100644 index 000000000..d73ef168f --- /dev/null +++ b/crates/superposition_mcp/src/generated/function.rs @@ -0,0 +1,175 @@ +// AUTO-GENERATED by smithy mcp-codegen — DO NOT EDIT +use rmcp::model::*; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::SuperpositionMcpServer; +use crate::helpers::*; + +/// Retrieves detailed information about a specific function including its published and draft versions, code, and metadata. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetFunctionParams { + pub function_name: String, +} + +/// Updates the draft version of an existing function with new code, runtime version, or description while preserving the published version. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct UpdateFunctionParams { + pub function_name: String, + pub description: Option, + pub change_reason: String, + pub function: Option, + pub runtime_version: Option, +} + +/// Permanently removes a function from the workspace, deleting both draft and published versions along with all associated code. It fails if already in use +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DeleteFunctionParams { + pub function_name: String, +} + +/// Retrieves a paginated list of all functions in the workspace with their basic information and current status. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListFunctionParams { + /// Number of items to be returned in each page. + pub count: Option, + /// Page number to retrieve, starting from 1. + pub page: Option, + /// If true, returns all requested items, ignoring pagination parameters page and count. + pub all: Option, + pub function_type: Option>, +} + +/// Creates a new custom function for value_validation, value_compute, context_validation or change_reason_validation with specified code, runtime version, and function type. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateFunctionParams { + pub function_name: String, + pub description: String, + pub change_reason: String, + pub function: String, + pub runtime_version: String, + pub function_type: String, +} + +/// Executes a function in test mode with provided input parameters to validate its behavior before publishing or deployment. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct TestFunctionParams { + pub function_name: String, + pub stage: String, +} + +/// Publishes the draft version of a function, making it the active version used for value_validation, value_compute, context_validation or change_reason_validation in the system. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct PublishFunctionParams { + pub function_name: String, + pub change_reason: String, +} + +impl SuperpositionMcpServer { + pub async fn get_function_impl(&self, args: GetFunctionParams) -> Result { + let mut req = self.client.get_function() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.function_name(args.function_name); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&function_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn update_function_impl(&self, args: UpdateFunctionParams) -> Result { + let mut req = self.client.update_function() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.function_name(args.function_name); + req = req.change_reason(args.change_reason); + if let Some(v) = args.description { + req = req.description(v); + } + if let Some(v) = args.function { + req = req.function(v); + } + if let Some(v) = args.runtime_version { + req = req.runtime_version(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&function_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn delete_function_impl(&self, args: DeleteFunctionParams) -> Result { + let mut req = self.client.delete_function() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.function_name(args.function_name); + let resp = req.send().await.map_err(mcp_err)?; + let json = "Deleted successfully".to_string(); + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn list_function_impl(&self, args: ListFunctionParams) -> Result { + let mut req = self.client.list_function() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + if let Some(v) = args.count { + req = req.count(v); + } + if let Some(v) = args.page { + req = req.page(v); + } + if let Some(v) = args.all { + req = req.all(v); + } + if let Some(v) = args.function_type { + for item in v { + req = req.function_type(item); + } + } + let resp = req.send().await.map_err(mcp_err)?; + let items: Vec = resp.data.iter().map(|r| function_to_json!(r)).collect(); + let result = serde_json::json!({ + "total_pages": resp.total_pages, + "total_items": resp.total_items, + "data": items, + }); + let json = serde_json::to_string_pretty(&result).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn create_function_impl(&self, args: CreateFunctionParams) -> Result { + let mut req = self.client.create_function() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.function_name(args.function_name); + req = req.description(args.description); + req = req.change_reason(args.change_reason); + req = req.function(args.function); + req = req.runtime_version(args.runtime_version); + req = req.function_type(args.function_type); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&function_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn test_function_impl(&self, args: TestFunctionParams) -> Result { + let mut req = self.client.test() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.function_name(args.function_name); + req = req.stage(args.stage); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&function_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn publish_function_impl(&self, args: PublishFunctionParams) -> Result { + let mut req = self.client.publish() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.function_name(args.function_name); + req = req.change_reason(args.change_reason); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&function_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + +} diff --git a/crates/superposition_mcp/src/generated/master_key.rs b/crates/superposition_mcp/src/generated/master_key.rs new file mode 100644 index 000000000..5696b55df --- /dev/null +++ b/crates/superposition_mcp/src/generated/master_key.rs @@ -0,0 +1,19 @@ +// AUTO-GENERATED by smithy mcp-codegen — DO NOT EDIT +use rmcp::model::*; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::SuperpositionMcpServer; +use crate::helpers::*; + +impl SuperpositionMcpServer { + pub async fn rotate_master_encryption_key_master_key_impl(&self) -> Result { + let mut req = self.client.rotate_master_encryption_key() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&master_key_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + +} diff --git a/crates/superposition_mcp/src/generated/mod.rs b/crates/superposition_mcp/src/generated/mod.rs new file mode 100644 index 000000000..813eeb7b1 --- /dev/null +++ b/crates/superposition_mcp/src/generated/mod.rs @@ -0,0 +1,42 @@ +// AUTO-GENERATED by smithy mcp-codegen — DO NOT EDIT + +pub mod tools { + mod default_config; + pub use default_config::*; + mod dimension; + pub use dimension::*; + mod context; + pub use context::*; + mod config; + pub use config::*; + mod config_version; + pub use config_version::*; + mod audit_log; + pub use audit_log::*; + mod function; + pub use function::*; + mod organisation; + pub use organisation::*; + mod experiments; + pub use experiments::*; + mod type_templates; + pub use type_templates::*; + mod workspace; + pub use workspace::*; + mod webhook; + pub use webhook::*; + mod experiment_group; + pub use experiment_group::*; + mod variable; + pub use variable::*; + mod secret; + pub use secret::*; + mod master_key; + pub use master_key::*; +} + +#[macro_use] +mod response_macros; + +mod server_tools; +pub use server_tools::*; diff --git a/crates/superposition_mcp/src/generated/organisation.rs b/crates/superposition_mcp/src/generated/organisation.rs new file mode 100644 index 000000000..f8902f40c --- /dev/null +++ b/crates/superposition_mcp/src/generated/organisation.rs @@ -0,0 +1,131 @@ +// AUTO-GENERATED by smithy mcp-codegen — DO NOT EDIT +use rmcp::model::*; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::SuperpositionMcpServer; +use crate::helpers::*; + +/// Creates a new organisation with specified name and administrator email. This is the top-level entity that contains workspaces and manages organizational-level settings. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateOrganisationParams { + pub country_code: Option, + pub contact_email: Option, + pub contact_phone: Option, + pub admin_email: String, + pub sector: Option, + pub name: String, +} + +/// Retrieves detailed information about a specific organisation including its status, contact details, and administrative metadata. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetOrganisationParams { + pub id: String, +} + +/// Updates an existing organisation's information including contact details, status, and administrative properties. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct UpdateOrganisationParams { + pub country_code: Option, + pub contact_email: Option, + pub contact_phone: Option, + pub admin_email: Option, + pub sector: Option, + pub id: String, + pub status: Option, +} + +/// Retrieves a paginated list of all organisations with their basic information, creation details, and current status. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListOrganisationParams { + /// Number of items to be returned in each page. + pub count: Option, + /// Page number to retrieve, starting from 1. + pub page: Option, + /// If true, returns all requested items, ignoring pagination parameters page and count. + pub all: Option, +} + +impl SuperpositionMcpServer { + pub async fn create_organisation_impl(&self, args: CreateOrganisationParams) -> Result { + let mut req = self.client.create_organisation() + .org_id(&self.config.org_id); + req = req.admin_email(args.admin_email); + req = req.name(args.name); + if let Some(v) = args.country_code { + req = req.country_code(v); + } + if let Some(v) = args.contact_email { + req = req.contact_email(v); + } + if let Some(v) = args.contact_phone { + req = req.contact_phone(v); + } + if let Some(v) = args.sector { + req = req.sector(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&organisation_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn get_organisation_impl(&self, args: GetOrganisationParams) -> Result { + let mut req = self.client.get_organisation() + .org_id(&self.config.org_id); + req = req.id(args.id); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&organisation_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn update_organisation_impl(&self, args: UpdateOrganisationParams) -> Result { + let mut req = self.client.update_organisation() + .org_id(&self.config.org_id); + req = req.id(args.id); + if let Some(v) = args.country_code { + req = req.country_code(v); + } + if let Some(v) = args.contact_email { + req = req.contact_email(v); + } + if let Some(v) = args.contact_phone { + req = req.contact_phone(v); + } + if let Some(v) = args.admin_email { + req = req.admin_email(v); + } + if let Some(v) = args.sector { + req = req.sector(v); + } + if let Some(v) = args.status { + req = req.status(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&organisation_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn list_organisation_impl(&self, args: ListOrganisationParams) -> Result { + let mut req = self.client.list_organisation() + .org_id(&self.config.org_id); + if let Some(v) = args.count { + req = req.count(v); + } + if let Some(v) = args.page { + req = req.page(v); + } + if let Some(v) = args.all { + req = req.all(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let items: Vec = resp.data.iter().map(|r| organisation_to_json!(r)).collect(); + let result = serde_json::json!({ + "total_pages": resp.total_pages, + "total_items": resp.total_items, + "data": items, + }); + let json = serde_json::to_string_pretty(&result).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + +} diff --git a/crates/superposition_mcp/src/generated/response_macros.rs b/crates/superposition_mcp/src/generated/response_macros.rs new file mode 100644 index 000000000..a6545cd8c --- /dev/null +++ b/crates/superposition_mcp/src/generated/response_macros.rs @@ -0,0 +1,315 @@ +// AUTO-GENERATED by smithy mcp-codegen — DO NOT EDIT + +macro_rules! default_config_to_json { + ($r:expr) => {{ + serde_json::json!({ + "key": $r.key, + "value": $crate::helpers::doc_to_json(&$r.value), + "schema": $crate::helpers::doc_map_to_json(&$r.schema), + "description": $r.description, + "change_reason": $r.change_reason, + "value_validation_function_name": $r.value_validation_function_name, + "value_compute_function_name": $r.value_compute_function_name, + "created_at": $crate::helpers::format_datetime(&$r.created_at), + "created_by": $r.created_by, + "last_modified_at": $crate::helpers::format_datetime(&$r.last_modified_at), + "last_modified_by": $r.last_modified_by, + }) + }} +} + +pub(crate) use default_config_to_json; + +macro_rules! dimension_to_json { + ($r:expr) => {{ + serde_json::json!({ + "dimension": $r.dimension, + "position": $r.position, + "schema": $crate::helpers::doc_map_to_json(&$r.schema), + "value_validation_function_name": $r.value_validation_function_name, + "description": $r.description, + "change_reason": $r.change_reason, + "last_modified_at": $crate::helpers::format_datetime(&$r.last_modified_at), + "last_modified_by": $r.last_modified_by, + "created_at": $crate::helpers::format_datetime(&$r.created_at), + "created_by": $r.created_by, + "dependency_graph": $r.dependency_graph, + "dimension_type": format!("{:?}", $r.dimension_type), + "value_compute_function_name": $r.value_compute_function_name, + "mandatory": $r.mandatory, + }) + }} +} + +pub(crate) use dimension_to_json; + +macro_rules! context_to_json { + ($r:expr) => {{ + serde_json::json!({ + "id": $r.id, + "value": $crate::helpers::doc_map_to_json(&$r.value), + "override": $crate::helpers::doc_map_to_json(&$r.r#override), + "override_id": $r.override_id, + "weight": $r.weight, + "description": $r.description, + "change_reason": $r.change_reason, + "created_at": $crate::helpers::format_datetime(&$r.created_at), + "created_by": $r.created_by, + "last_modified_at": $crate::helpers::format_datetime(&$r.last_modified_at), + "last_modified_by": $r.last_modified_by, + }) + }} +} + +pub(crate) use context_to_json; + +macro_rules! config_to_json { + ($r:expr) => {{ + serde_json::json!({ + "contexts": $r.contexts, + "overrides": $r.overrides, + "default_configs": $crate::helpers::doc_map_to_json(&$r.default_configs), + "dimensions": $r.dimensions, + "version": $r.version, + "last_modified": $crate::helpers::format_datetime(&$r.last_modified), + "audit_id": $r.audit_id, + }) + }} +} + +pub(crate) use config_to_json; + +macro_rules! config_version_to_json { + ($r:expr) => {{ + serde_json::json!({ + "id": $r.id, + "config": $crate::helpers::doc_to_json(&$r.config), + "config_hash": $r.config_hash, + "created_at": $crate::helpers::format_datetime(&$r.created_at), + "description": $r.description, + "tags": $r.tags, + }) + }} +} + +pub(crate) use config_version_to_json; + +macro_rules! audit_log_to_json { + ($r:expr) => {{ + serde_json::json!({ + "total_pages": $r.total_pages, + "total_items": $r.total_items, + "data": $r.data, + }) + }} +} + +pub(crate) use audit_log_to_json; + +macro_rules! function_to_json { + ($r:expr) => {{ + serde_json::json!({ + "function_name": $r.function_name, + "published_code": $r.published_code, + "draft_code": $r.draft_code, + "published_runtime_version": format!("{:?}", $r.published_runtime_version), + "draft_runtime_version": format!("{:?}", $r.draft_runtime_version), + "published_at": $r.published_at.as_ref().map($crate::helpers::format_datetime), + "draft_edited_at": $crate::helpers::format_datetime(&$r.draft_edited_at), + "published_by": $r.published_by, + "draft_edited_by": $r.draft_edited_by, + "last_modified_at": $crate::helpers::format_datetime(&$r.last_modified_at), + "last_modified_by": $r.last_modified_by, + "change_reason": $r.change_reason, + "description": $r.description, + "function_type": format!("{:?}", $r.function_type), + }) + }} +} + +pub(crate) use function_to_json; + +macro_rules! organisation_to_json { + ($r:expr) => {{ + serde_json::json!({ + "id": $r.id, + "name": $r.name, + "country_code": $r.country_code, + "contact_email": $r.contact_email, + "contact_phone": $r.contact_phone, + "created_by": $r.created_by, + "admin_email": $r.admin_email, + "status": format!("{:?}", $r.status), + "sector": $r.sector, + "created_at": $crate::helpers::format_datetime(&$r.created_at), + "updated_at": $crate::helpers::format_datetime(&$r.updated_at), + "updated_by": $r.updated_by, + }) + }} +} + +pub(crate) use organisation_to_json; + +macro_rules! experiments_to_json { + ($r:expr) => {{ + serde_json::json!({ + "id": $r.id, + "created_at": $crate::helpers::format_datetime(&$r.created_at), + "created_by": $r.created_by, + "last_modified": $crate::helpers::format_datetime(&$r.last_modified), + "name": $r.name, + "experiment_type": format!("{:?}", $r.experiment_type), + "override_keys": $r.override_keys, + "status": format!("{:?}", $r.status), + "traffic_percentage": $r.traffic_percentage, + "context": $crate::helpers::doc_map_to_json(&$r.context), + "variants": $r.variants, + "last_modified_by": $r.last_modified_by, + "chosen_variant": $r.chosen_variant, + "description": $r.description, + "change_reason": $r.change_reason, + "started_at": $r.started_at.as_ref().map($crate::helpers::format_datetime), + "started_by": $r.started_by, + "metrics_url": $r.metrics_url, + "metrics": $r.metrics.as_ref().map($crate::helpers::doc_to_json), + "experiment_group_id": $r.experiment_group_id, + }) + }} +} + +pub(crate) use experiments_to_json; + +macro_rules! type_templates_to_json { + ($r:expr) => {{ + serde_json::json!({ + "type_name": $r.type_name, + "type_schema": $crate::helpers::doc_map_to_json(&$r.type_schema), + "description": $r.description, + "change_reason": $r.change_reason, + "created_by": $r.created_by, + "created_at": $crate::helpers::format_datetime(&$r.created_at), + "last_modified_at": $crate::helpers::format_datetime(&$r.last_modified_at), + "last_modified_by": $r.last_modified_by, + }) + }} +} + +pub(crate) use type_templates_to_json; + +macro_rules! workspace_to_json { + ($r:expr) => {{ + serde_json::json!({ + "workspace_name": $r.workspace_name, + "organisation_id": $r.organisation_id, + "organisation_name": $r.organisation_name, + "workspace_schema_name": $r.workspace_schema_name, + "workspace_status": format!("{:?}", $r.workspace_status), + "workspace_admin_email": $r.workspace_admin_email, + "config_version": $r.config_version, + "created_by": $r.created_by, + "last_modified_by": $r.last_modified_by, + "last_modified_at": $crate::helpers::format_datetime(&$r.last_modified_at), + "created_at": $crate::helpers::format_datetime(&$r.created_at), + "mandatory_dimensions": $r.mandatory_dimensions, + "metrics": $crate::helpers::doc_to_json(&$r.metrics), + "allow_experiment_self_approval": $r.allow_experiment_self_approval, + "auto_populate_control": $r.auto_populate_control, + "enable_context_validation": $r.enable_context_validation, + "enable_change_reason_validation": $r.enable_change_reason_validation, + }) + }} +} + +pub(crate) use workspace_to_json; + +macro_rules! webhook_to_json { + ($r:expr) => {{ + serde_json::json!({ + "name": $r.name, + "description": $r.description, + "enabled": $r.enabled, + "url": $r.url, + "method": format!("{:?}", $r.method), + "version": format!("{:?}", $r.version), + "custom_headers": $r.custom_headers.as_ref().map($crate::helpers::doc_map_to_json), + "events": $r.events, + "max_retries": $r.max_retries, + "last_triggered_at": $r.last_triggered_at.as_ref().map($crate::helpers::format_datetime), + "change_reason": $r.change_reason, + "created_by": $r.created_by, + "created_at": $crate::helpers::format_datetime(&$r.created_at), + "last_modified_by": $r.last_modified_by, + "last_modified_at": $crate::helpers::format_datetime(&$r.last_modified_at), + }) + }} +} + +pub(crate) use webhook_to_json; + +macro_rules! experiment_group_to_json { + ($r:expr) => {{ + serde_json::json!({ + "id": $r.id, + "context_hash": $r.context_hash, + "name": $r.name, + "description": $r.description, + "change_reason": $r.change_reason, + "context": $crate::helpers::doc_map_to_json(&$r.context), + "traffic_percentage": $r.traffic_percentage, + "member_experiment_ids": $r.member_experiment_ids, + "created_at": $crate::helpers::format_datetime(&$r.created_at), + "created_by": $r.created_by, + "last_modified_at": $crate::helpers::format_datetime(&$r.last_modified_at), + "last_modified_by": $r.last_modified_by, + "buckets": $r.buckets, + "group_type": format!("{:?}", $r.group_type), + }) + }} +} + +pub(crate) use experiment_group_to_json; + +macro_rules! variable_to_json { + ($r:expr) => {{ + serde_json::json!({ + "name": $r.name, + "value": $r.value, + "description": $r.description, + "change_reason": $r.change_reason, + "created_by": $r.created_by, + "created_at": $crate::helpers::format_datetime(&$r.created_at), + "last_modified_by": $r.last_modified_by, + "last_modified_at": $crate::helpers::format_datetime(&$r.last_modified_at), + }) + }} +} + +pub(crate) use variable_to_json; + +macro_rules! secret_to_json { + ($r:expr) => {{ + serde_json::json!({ + "name": $r.name, + "description": $r.description, + "change_reason": $r.change_reason, + "created_by": $r.created_by, + "created_at": $crate::helpers::format_datetime(&$r.created_at), + "last_modified_by": $r.last_modified_by, + "last_modified_at": $crate::helpers::format_datetime(&$r.last_modified_at), + }) + }} +} + +pub(crate) use secret_to_json; + +macro_rules! master_key_to_json { + ($r:expr) => {{ + serde_json::json!({ + "workspaces_rotated": $r.workspaces_rotated, + "total_secrets_re_encrypted": $r.total_secrets_re_encrypted, + }) + }} +} + +pub(crate) use master_key_to_json; + diff --git a/crates/superposition_mcp/src/generated/secret.rs b/crates/superposition_mcp/src/generated/secret.rs new file mode 100644 index 000000000..859899214 --- /dev/null +++ b/crates/superposition_mcp/src/generated/secret.rs @@ -0,0 +1,158 @@ +// AUTO-GENERATED by smithy mcp-codegen — DO NOT EDIT +use rmcp::model::*; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::SuperpositionMcpServer; +use crate::helpers::*; + +/// Retrieves detailed information about a specific secret by its name. The value is masked for security. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetSecretParams { + pub name: String, +} + +/// Updates an existing secret's value or description. The value is re-encrypted with the current workspace encryption key. Returns masked value. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct UpdateSecretParams { + pub name: String, + /// New plaintext value to encrypt and store. If provided, will be encrypted with current key. + pub value: Option, + pub description: Option, + pub change_reason: String, +} + +/// Permanently deletes a secret from the workspace. The encrypted value is removed and cannot be recovered. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DeleteSecretParams { + pub name: String, +} + +/// Retrieves a paginated list of all secrets in the workspace with optional filtering and sorting. All secret values are masked. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListSecretParams { + /// Number of items to be returned in each page. + pub count: Option, + /// Page number to retrieve, starting from 1. + pub page: Option, + /// If true, returns all requested items, ignoring pagination parameters page and count. + pub all: Option, + /// Filter by secret name. + pub name: Option>, + /// Filter by the user who created the secret. + pub created_by: Option>, + /// Filter by the user who last modified the secret. + pub last_modified_by: Option>, + /// Field to sort the results by. + pub sort_on: Option, + /// Sort order (ascending or descending). + pub sort_by: Option, +} + +/// Creates a new encrypted secret with the specified name and value. The secret is encrypted with the workspace's current encryption key. Secret values are never returned in responses for security. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateSecretParams { + pub name: String, + /// Plaintext value to be encrypted and stored. + pub value: String, + pub description: String, + pub change_reason: String, +} + +impl SuperpositionMcpServer { + pub async fn get_secret_impl(&self, args: GetSecretParams) -> Result { + let mut req = self.client.get_secret() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.name(args.name); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&secret_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn update_secret_impl(&self, args: UpdateSecretParams) -> Result { + let mut req = self.client.update_secret() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.name(args.name); + req = req.change_reason(args.change_reason); + if let Some(v) = args.value { + req = req.value(v); + } + if let Some(v) = args.description { + req = req.description(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&secret_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn delete_secret_impl(&self, args: DeleteSecretParams) -> Result { + let mut req = self.client.delete_secret() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.name(args.name); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&secret_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn list_secret_impl(&self, args: ListSecretParams) -> Result { + let mut req = self.client.list_secrets() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + if let Some(v) = args.count { + req = req.count(v); + } + if let Some(v) = args.page { + req = req.page(v); + } + if let Some(v) = args.all { + req = req.all(v); + } + if let Some(v) = args.name { + for item in v { + req = req.name(item); + } + } + if let Some(v) = args.created_by { + for item in v { + req = req.created_by(item); + } + } + if let Some(v) = args.last_modified_by { + for item in v { + req = req.last_modified_by(item); + } + } + if let Some(v) = args.sort_on { + req = req.sort_on(v); + } + if let Some(v) = args.sort_by { + req = req.sort_by(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let items: Vec = resp.data.iter().map(|r| secret_to_json!(r)).collect(); + let result = serde_json::json!({ + "total_pages": resp.total_pages, + "total_items": resp.total_items, + "data": items, + }); + let json = serde_json::to_string_pretty(&result).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn create_secret_impl(&self, args: CreateSecretParams) -> Result { + let mut req = self.client.create_secret() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.name(args.name); + req = req.value(args.value); + req = req.description(args.description); + req = req.change_reason(args.change_reason); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&secret_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + +} diff --git a/crates/superposition_mcp/src/generated/server_tools.rs b/crates/superposition_mcp/src/generated/server_tools.rs new file mode 100644 index 000000000..c51c6e791 --- /dev/null +++ b/crates/superposition_mcp/src/generated/server_tools.rs @@ -0,0 +1,947 @@ +// AUTO-GENERATED by smithy mcp-codegen — DO NOT EDIT +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::*; +use rmcp::{tool, tool_router}; + +use crate::SuperpositionMcpServer; +use crate::generated::tools::*; + +#[tool_router] +impl SuperpositionMcpServer { + // ===== DefaultConfig ===== + #[tool( + name = "default_config.get", + description = "Retrieves a specific default config entry by its key, including its value, schema, function mappings, and metadata." + )] + async fn default_config_get( + &self, + Parameters(args): Parameters, + ) -> Result { + self.get_default_config_impl(args).await + } + + #[tool( + name = "default_config.update", + description = "Updates an existing default config entry. Allows modification of value, schema, function mappings, and description while preserving the key identifier." + )] + async fn default_config_update( + &self, + Parameters(args): Parameters, + ) -> Result { + self.update_default_config_impl(args).await + } + + #[tool( + name = "default_config.delete", + description = "Permanently removes a default config entry from the workspace. This operation cannot be performed if it affects config resolution for contexts that rely on this fallback value." + )] + async fn default_config_delete( + &self, + Parameters(args): Parameters, + ) -> Result { + self.delete_default_config_impl(args).await + } + + #[tool( + name = "default_config.list", + description = "Retrieves a paginated list of all default config entries in the workspace, including their values, schemas, and metadata." + )] + async fn default_config_list( + &self, + Parameters(args): Parameters, + ) -> Result { + self.list_default_config_impl(args).await + } + + #[tool( + name = "default_config.create", + description = "Creates a new default config entry with specified key, value, schema, and metadata. Default configs serve as fallback values when no specific context matches." + )] + async fn default_config_create( + &self, + Parameters(args): Parameters, + ) -> Result { + self.create_default_config_impl(args).await + } + + // ===== Dimension ===== + #[tool( + name = "dimension.get", + description = "Retrieves detailed information about a specific dimension, including its schema, cohort dependency graph, and configuration metadata." + )] + async fn dimension_get( + &self, + Parameters(args): Parameters, + ) -> Result { + self.get_dimension_impl(args).await + } + + #[tool( + name = "dimension.update", + description = "Updates an existing dimension's configuration. Allows modification of schema, position, function mappings, and other properties while maintaining dependency relationships." + )] + async fn dimension_update( + &self, + Parameters(args): Parameters, + ) -> Result { + self.update_dimension_impl(args).await + } + + #[tool( + name = "dimension.delete", + description = "Permanently removes a dimension from the workspace. This operation will fail if the dimension has active dependencies or is referenced by existing configurations." + )] + async fn dimension_delete( + &self, + Parameters(args): Parameters, + ) -> Result { + self.delete_dimension_impl(args).await + } + + #[tool( + name = "dimension.list", + description = "Retrieves a paginated list of all dimensions in the workspace. Dimensions are returned with their details and metadata." + )] + async fn dimension_list( + &self, + Parameters(args): Parameters, + ) -> Result { + self.list_dimension_impl(args).await + } + + #[tool( + name = "dimension.create", + description = "Creates a new dimension with the specified json schema. Dimensions define categorical attributes used for context-based config management." + )] + async fn dimension_create( + &self, + Parameters(args): Parameters, + ) -> Result { + self.create_dimension_impl(args).await + } + + // ===== Context ===== + #[tool( + name = "context.create", + description = "Creates a new context with specified conditions and overrides. Contexts define conditional rules for config management." + )] + async fn context_create( + &self, + Parameters(args): Parameters, + ) -> Result { + self.create_context_impl(args).await + } + + #[tool( + name = "context.get", + description = "Retrieves detailed information about a specific context by its unique identifier, including conditions, overrides, and metadata." + )] + async fn context_get( + &self, + Parameters(args): Parameters, + ) -> Result { + self.get_context_impl(args).await + } + + #[tool( + name = "context.delete", + description = "Permanently removes a context from the workspace. This operation cannot be undone and will affect config resolution." + )] + async fn context_delete( + &self, + Parameters(args): Parameters, + ) -> Result { + self.delete_context_impl(args).await + } + + #[tool( + name = "context.list", + description = "Retrieves a paginated list of contexts with support for filtering by creation date, modification date, weight, and other criteria." + )] + async fn context_list( + &self, + Parameters(args): Parameters, + ) -> Result { + self.list_context_impl(args).await + } + + #[tool( + name = "context.move", + description = "Updates the condition of the mentioned context, if a context with the new condition already exists, it merges the override and effectively deleting the old context" + )] + async fn context_move( + &self, + Parameters(args): Parameters, + ) -> Result { + self.move_context_impl(args).await + } + + #[tool( + name = "context.update_override", + description = "Updates the overrides for an existing context. Allows modification of override values while maintaining the context's conditions." + )] + async fn context_update_override( + &self, + Parameters(args): Parameters, + ) -> Result { + self.update_override_context_impl(args).await + } + + #[tool( + name = "context.get_context_from_condition", + description = "Retrieves context information by matching against provided conditions. Used to find contexts that would apply to specific scenarios." + )] + async fn context_get_context_from_condition(&self) -> Result { + self.get_context_from_condition_context_impl().await + } + + #[tool( + name = "context.weight_recompute", + description = "Recalculates and updates the priority weights for all contexts in the workspace based on their dimensions." + )] + async fn context_weight_recompute( + &self, + Parameters(args): Parameters, + ) -> Result { + self.weight_recompute_context_impl(args).await + } + + #[tool( + name = "context.bulk_operation", + description = "Executes multiple context operations (PUT, REPLACE, DELETE, MOVE) in a single atomic transaction for efficient batch processing." + )] + async fn context_bulk_operation( + &self, + Parameters(args): Parameters, + ) -> Result { + self.bulk_operation_context_impl(args).await + } + + #[tool( + name = "context.validate", + description = "Validates if a given context condition is well-formed" + )] + async fn context_validate( + &self, + Parameters(args): Parameters, + ) -> Result { + self.validate_context_impl(args).await + } + + // ===== Config ===== + #[tool( + name = "config.get_config_fast", + description = "Retrieves the latest config with no processing for high-performance access." + )] + async fn config_get_config_fast(&self) -> Result { + self.get_config_fast_config_impl().await + } + + #[tool( + name = "config.get", + description = "Retrieves config data with context evaluation, including applicable contexts, overrides, and default values based on provided conditions." + )] + async fn config_get( + &self, + Parameters(args): Parameters, + ) -> Result { + self.get_config_impl(args).await + } + + #[tool( + name = "config.get_resolved_config", + description = "Resolves and merges config values based on context conditions, applying overrides and merge strategies to produce the final configuration." + )] + async fn config_get_resolved_config( + &self, + Parameters(args): Parameters, + ) -> Result { + self.get_resolved_config_config_impl(args).await + } + + #[tool( + name = "config.get_resolved_config_with_identifier", + description = "Resolves and merges config values based on context conditions and identifier, applying overrides and merge strategies to produce the final configuration." + )] + async fn config_get_resolved_config_with_identifier( + &self, + Parameters(args): Parameters, + ) -> Result { + self.get_resolved_config_with_identifier_config_impl(args).await + } + + #[tool( + name = "config.get_config_toml", + description = "Retrieves the full config in TOML format, including default configs with schemas, dimensions, and overrides. This endpoint is optimized for clients that prefer TOML format for configuration managem..." + )] + async fn config_get_config_toml(&self) -> Result { + self.get_config_toml_config_impl().await + } + + #[tool( + name = "config.get_config_json", + description = "Retrieves the full config in JSON format, including default configs with schemas, dimensions, and overrides. This endpoint is optimized for clients that prefer JSON format for configuration managem..." + )] + async fn config_get_config_json(&self) -> Result { + self.get_config_json_config_impl().await + } + + // ===== ConfigVersion ===== + #[tool( + name = "config_version.get", + description = "Retrieves a specific config version along with its metadata for audit and rollback purposes." + )] + async fn config_version_get( + &self, + Parameters(args): Parameters, + ) -> Result { + self.get_config_version_impl(args).await + } + + #[tool( + name = "config_version.list", + description = "Retrieves a paginated list of config versions with their metadata, hash values, and creation timestamps for audit and rollback purposes." + )] + async fn config_version_list( + &self, + Parameters(args): Parameters, + ) -> Result { + self.list_config_version_impl(args).await + } + + // ===== AuditLog ===== + #[tool( + name = "audit_log.list", + description = "Retrieves a paginated list of audit logs with support for filtering by date range, table names, actions, and usernames for compliance and monitoring purposes." + )] + async fn audit_log_list( + &self, + Parameters(args): Parameters, + ) -> Result { + self.list_audit_log_impl(args).await + } + + // ===== Function ===== + #[tool( + name = "function.get", + description = "Retrieves detailed information about a specific function including its published and draft versions, code, and metadata." + )] + async fn function_get( + &self, + Parameters(args): Parameters, + ) -> Result { + self.get_function_impl(args).await + } + + #[tool( + name = "function.update", + description = "Updates the draft version of an existing function with new code, runtime version, or description while preserving the published version." + )] + async fn function_update( + &self, + Parameters(args): Parameters, + ) -> Result { + self.update_function_impl(args).await + } + + #[tool( + name = "function.delete", + description = "Permanently removes a function from the workspace, deleting both draft and published versions along with all associated code. It fails if already in use" + )] + async fn function_delete( + &self, + Parameters(args): Parameters, + ) -> Result { + self.delete_function_impl(args).await + } + + #[tool( + name = "function.list", + description = "Retrieves a paginated list of all functions in the workspace with their basic information and current status." + )] + async fn function_list( + &self, + Parameters(args): Parameters, + ) -> Result { + self.list_function_impl(args).await + } + + #[tool( + name = "function.create", + description = "Creates a new custom function for value_validation, value_compute, context_validation or change_reason_validation with specified code, runtime version, and function type." + )] + async fn function_create( + &self, + Parameters(args): Parameters, + ) -> Result { + self.create_function_impl(args).await + } + + #[tool( + name = "function.test", + description = "Executes a function in test mode with provided input parameters to validate its behavior before publishing or deployment." + )] + async fn function_test( + &self, + Parameters(args): Parameters, + ) -> Result { + self.test_function_impl(args).await + } + + #[tool( + name = "function.publish", + description = "Publishes the draft version of a function, making it the active version used for value_validation, value_compute, context_validation or change_reason_validation in the system." + )] + async fn function_publish( + &self, + Parameters(args): Parameters, + ) -> Result { + self.publish_function_impl(args).await + } + + // ===== Organisation ===== + #[tool( + name = "organisation.create", + description = "Creates a new organisation with specified name and administrator email. This is the top-level entity that contains workspaces and manages organizational-level settings." + )] + async fn organisation_create( + &self, + Parameters(args): Parameters, + ) -> Result { + self.create_organisation_impl(args).await + } + + #[tool( + name = "organisation.get", + description = "Retrieves detailed information about a specific organisation including its status, contact details, and administrative metadata." + )] + async fn organisation_get( + &self, + Parameters(args): Parameters, + ) -> Result { + self.get_organisation_impl(args).await + } + + #[tool( + name = "organisation.update", + description = "Updates an existing organisation's information including contact details, status, and administrative properties." + )] + async fn organisation_update( + &self, + Parameters(args): Parameters, + ) -> Result { + self.update_organisation_impl(args).await + } + + #[tool( + name = "organisation.list", + description = "Retrieves a paginated list of all organisations with their basic information, creation details, and current status." + )] + async fn organisation_list( + &self, + Parameters(args): Parameters, + ) -> Result { + self.list_organisation_impl(args).await + } + + // ===== Experiments ===== + #[tool( + name = "experiments.create", + description = "Creates a new experiment with variants, context and conditions. You can optionally specify metrics and experiment group for tracking and analysis." + )] + async fn experiments_create( + &self, + Parameters(args): Parameters, + ) -> Result { + self.create_experiments_impl(args).await + } + + #[tool( + name = "experiments.get", + description = "Retrieves detailed information about a specific experiment, including its config, variants, status, and metrics." + )] + async fn experiments_get( + &self, + Parameters(args): Parameters, + ) -> Result { + self.get_experiments_impl(args).await + } + + #[tool( + name = "experiments.list", + description = "Retrieves a paginated list of experiments with support for filtering by status, date range, name, creator, and experiment group." + )] + async fn experiments_list( + &self, + Parameters(args): Parameters, + ) -> Result { + self.list_experiments_impl(args).await + } + + #[tool( + name = "experiments.update_overrides_experiment", + description = "Updates the overrides for specific variants within an experiment, allowing modification of experiment behavior Updates the overrides for specific variants within an experiment, allowing modificatio..." + )] + async fn experiments_update_overrides_experiment( + &self, + Parameters(args): Parameters, + ) -> Result { + self.update_overrides_experiment_experiments_impl(args).await + } + + #[tool( + name = "experiments.conclude_experiment", + description = "Concludes an inprogress experiment by selecting a winning variant and transitioning the experiment to a concluded state." + )] + async fn experiments_conclude_experiment( + &self, + Parameters(args): Parameters, + ) -> Result { + self.conclude_experiment_experiments_impl(args).await + } + + #[tool( + name = "experiments.discard_experiment", + description = "Discards an experiment without selecting a winner, effectively canceling the experiment and removing its effects." + )] + async fn experiments_discard_experiment( + &self, + Parameters(args): Parameters, + ) -> Result { + self.discard_experiment_experiments_impl(args).await + } + + #[tool( + name = "experiments.ramp_experiment", + description = "Adjusts the traffic percentage allocation for an in-progress experiment, allowing gradual rollout or rollback of experimental features." + )] + async fn experiments_ramp_experiment( + &self, + Parameters(args): Parameters, + ) -> Result { + self.ramp_experiment_experiments_impl(args).await + } + + #[tool( + name = "experiments.pause_experiment", + description = "Temporarily pauses an inprogress experiment, suspending its effects while preserving the experiment config for later resumption." + )] + async fn experiments_pause_experiment( + &self, + Parameters(args): Parameters, + ) -> Result { + self.pause_experiment_experiments_impl(args).await + } + + #[tool( + name = "experiments.resume_experiment", + description = "Resumes a previously paused experiment, restoring its in-progress state and re-enabling variant evaluation." + )] + async fn experiments_resume_experiment( + &self, + Parameters(args): Parameters, + ) -> Result { + self.resume_experiment_experiments_impl(args).await + } + + #[tool( + name = "experiments.applicable_variants", + description = "Determines which experiment variants are applicable to a given context, used for experiment evaluation and variant selection." + )] + async fn experiments_applicable_variants( + &self, + Parameters(args): Parameters, + ) -> Result { + self.applicable_variants_experiments_impl(args).await + } + + // ===== TypeTemplates ===== + #[tool( + name = "type_templates.get", + description = "Retrieves detailed information about a specific type template including its schema and metadata." + )] + async fn type_templates_get( + &self, + Parameters(args): Parameters, + ) -> Result { + self.get_type_templates_impl(args).await + } + + #[tool( + name = "type_templates.update", + description = "Updates an existing type template's schema definition and metadata while preserving its identifier and usage history." + )] + async fn type_templates_update( + &self, + Parameters(args): Parameters, + ) -> Result { + self.update_type_templates_impl(args).await + } + + #[tool( + name = "type_templates.delete", + description = "Permanently removes a type template from the workspace. No checks performed while deleting" + )] + async fn type_templates_delete( + &self, + Parameters(args): Parameters, + ) -> Result { + self.delete_type_templates_impl(args).await + } + + #[tool( + name = "type_templates.list", + description = "Retrieves a paginated list of all type templates in the workspace, including their schemas and metadata for type management." + )] + async fn type_templates_list( + &self, + Parameters(args): Parameters, + ) -> Result { + self.list_type_templates_impl(args).await + } + + #[tool( + name = "type_templates.create", + description = "Creates a new type template with specified schema definition, providing reusable type definitions for config validation." + )] + async fn type_templates_create( + &self, + Parameters(args): Parameters, + ) -> Result { + self.create_type_templates_impl(args).await + } + + // ===== Workspace ===== + #[tool( + name = "workspace.get", + description = "Retrieves detailed information about a specific workspace including its configuration and metadata." + )] + async fn workspace_get( + &self, + Parameters(args): Parameters, + ) -> Result { + self.get_workspace_impl(args).await + } + + #[tool( + name = "workspace.update", + description = "Updates an existing workspace configuration, allowing modification of admin settings, mandatory dimensions, and workspace properties. Validates config version existence if provided." + )] + async fn workspace_update( + &self, + Parameters(args): Parameters, + ) -> Result { + self.update_workspace_impl(args).await + } + + #[tool( + name = "workspace.list", + description = "Retrieves a paginated list of all workspaces with optional filtering by workspace name, including their status, config details, and administrative information." + )] + async fn workspace_list( + &self, + Parameters(args): Parameters, + ) -> Result { + self.list_workspace_impl(args).await + } + + #[tool( + name = "workspace.create", + description = "Creates a new workspace within an organisation, including database schema setup and isolated environment for config management with specified admin and settings." + )] + async fn workspace_create( + &self, + Parameters(args): Parameters, + ) -> Result { + self.create_workspace_impl(args).await + } + + #[tool( + name = "workspace.migrate_workspace_schema", + description = "Migrates the workspace database schema to the new version of the template" + )] + async fn workspace_migrate_workspace_schema( + &self, + Parameters(args): Parameters, + ) -> Result { + self.migrate_workspace_schema_workspace_impl(args).await + } + + #[tool( + name = "workspace.rotate_workspace_encryption_key", + description = "Rotates the workspace encryption key. Generates a new encryption key and re-encrypts all secrets with the new key. This is a critical operation that should be done during low-traffic periods." + )] + async fn workspace_rotate_workspace_encryption_key( + &self, + Parameters(args): Parameters, + ) -> Result { + self.rotate_workspace_encryption_key_workspace_impl(args).await + } + + // ===== Webhook ===== + #[tool( + name = "webhook.get", + description = "Retrieves detailed information about a specific webhook config, including its events, headers, and trigger history." + )] + async fn webhook_get( + &self, + Parameters(args): Parameters, + ) -> Result { + self.get_webhook_impl(args).await + } + + #[tool( + name = "webhook.update", + description = "Updates an existing webhook config, allowing modification of URL, events, headers, and other webhook properties." + )] + async fn webhook_update( + &self, + Parameters(args): Parameters, + ) -> Result { + self.update_webhook_impl(args).await + } + + #[tool( + name = "webhook.delete", + description = "Permanently removes a webhook config from the workspace, stopping all future event notifications to that endpoint." + )] + async fn webhook_delete( + &self, + Parameters(args): Parameters, + ) -> Result { + self.delete_webhook_impl(args).await + } + + #[tool( + name = "webhook.list", + description = "Retrieves a paginated list of all webhook configs in the workspace, including their status and config details." + )] + async fn webhook_list( + &self, + Parameters(args): Parameters, + ) -> Result { + self.list_webhook_impl(args).await + } + + #[tool( + name = "webhook.create", + description = "Creates a new webhook config to receive HTTP notifications when specified events occur in the system." + )] + async fn webhook_create( + &self, + Parameters(args): Parameters, + ) -> Result { + self.create_webhook_impl(args).await + } + + #[tool( + name = "webhook.get_webhook_by_event", + description = "Retrieves a webhook configuration based on a specific event type, allowing users to find which webhook is set to trigger for that event." + )] + async fn webhook_get_webhook_by_event( + &self, + Parameters(args): Parameters, + ) -> Result { + self.get_webhook_by_event_webhook_impl(args).await + } + + // ===== ExperimentGroup ===== + #[tool( + name = "experiment_group.create", + description = "Creates a new experiment group." + )] + async fn experiment_group_create( + &self, + Parameters(args): Parameters, + ) -> Result { + self.create_experiment_group_impl(args).await + } + + #[tool( + name = "experiment_group.get", + description = "Retrieves an existing experiment group by its ID." + )] + async fn experiment_group_get( + &self, + Parameters(args): Parameters, + ) -> Result { + self.get_experiment_group_impl(args).await + } + + #[tool( + name = "experiment_group.update", + description = "Updates an existing experiment group. Allows partial updates to specified fields." + )] + async fn experiment_group_update( + &self, + Parameters(args): Parameters, + ) -> Result { + self.update_experiment_group_impl(args).await + } + + #[tool( + name = "experiment_group.delete", + description = "Deletes an experiment group." + )] + async fn experiment_group_delete( + &self, + Parameters(args): Parameters, + ) -> Result { + self.delete_experiment_group_impl(args).await + } + + #[tool( + name = "experiment_group.list", + description = "Lists experiment groups, with support for filtering and pagination." + )] + async fn experiment_group_list( + &self, + Parameters(args): Parameters, + ) -> Result { + self.list_experiment_group_impl(args).await + } + + #[tool( + name = "experiment_group.add_members_to_group", + description = "Adds members to an existing experiment group." + )] + async fn experiment_group_add_members_to_group( + &self, + Parameters(args): Parameters, + ) -> Result { + self.add_members_to_group_experiment_group_impl(args).await + } + + #[tool( + name = "experiment_group.remove_members_from_group", + description = "Removes members from an existing experiment group." + )] + async fn experiment_group_remove_members_from_group( + &self, + Parameters(args): Parameters, + ) -> Result { + self.remove_members_from_group_experiment_group_impl(args).await + } + + // ===== Variable ===== + #[tool( + name = "variable.get", + description = "Retrieves detailed information about a specific variable by its name." + )] + async fn variable_get( + &self, + Parameters(args): Parameters, + ) -> Result { + self.get_variable_impl(args).await + } + + #[tool( + name = "variable.update", + description = "Updates an existing variable's value, description, or tags." + )] + async fn variable_update( + &self, + Parameters(args): Parameters, + ) -> Result { + self.update_variable_impl(args).await + } + + #[tool( + name = "variable.delete", + description = "Permanently deletes a variable from the workspace." + )] + async fn variable_delete( + &self, + Parameters(args): Parameters, + ) -> Result { + self.delete_variable_impl(args).await + } + + #[tool( + name = "variable.list", + description = "Retrieves a paginated list of all variables in the workspace with optional filtering and sorting." + )] + async fn variable_list( + &self, + Parameters(args): Parameters, + ) -> Result { + self.list_variable_impl(args).await + } + + #[tool( + name = "variable.create", + description = "Creates a new variable with the specified name and value." + )] + async fn variable_create( + &self, + Parameters(args): Parameters, + ) -> Result { + self.create_variable_impl(args).await + } + + // ===== Secret ===== + #[tool( + name = "secret.get", + description = "Retrieves detailed information about a specific secret by its name. The value is masked for security." + )] + async fn secret_get( + &self, + Parameters(args): Parameters, + ) -> Result { + self.get_secret_impl(args).await + } + + #[tool( + name = "secret.update", + description = "Updates an existing secret's value or description. The value is re-encrypted with the current workspace encryption key. Returns masked value." + )] + async fn secret_update( + &self, + Parameters(args): Parameters, + ) -> Result { + self.update_secret_impl(args).await + } + + #[tool( + name = "secret.delete", + description = "Permanently deletes a secret from the workspace. The encrypted value is removed and cannot be recovered." + )] + async fn secret_delete( + &self, + Parameters(args): Parameters, + ) -> Result { + self.delete_secret_impl(args).await + } + + #[tool( + name = "secret.list", + description = "Retrieves a paginated list of all secrets in the workspace with optional filtering and sorting. All secret values are masked." + )] + async fn secret_list( + &self, + Parameters(args): Parameters, + ) -> Result { + self.list_secret_impl(args).await + } + + #[tool( + name = "secret.create", + description = "Creates a new encrypted secret with the specified name and value. The secret is encrypted with the workspace's current encryption key. Secret values are never returned in responses for security." + )] + async fn secret_create( + &self, + Parameters(args): Parameters, + ) -> Result { + self.create_secret_impl(args).await + } + + // ===== MasterKey ===== + #[tool( + name = "master_key.rotate_master_encryption_key", + description = "Rotates the master encryption key across all workspaces" + )] + async fn master_key_rotate_master_encryption_key(&self) -> Result { + self.rotate_master_encryption_key_master_key_impl().await + } + +} diff --git a/crates/superposition_mcp/src/generated/type_templates.rs b/crates/superposition_mcp/src/generated/type_templates.rs new file mode 100644 index 000000000..9aa780948 --- /dev/null +++ b/crates/superposition_mcp/src/generated/type_templates.rs @@ -0,0 +1,123 @@ +// AUTO-GENERATED by smithy mcp-codegen — DO NOT EDIT +use rmcp::model::*; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::SuperpositionMcpServer; +use crate::helpers::*; + +/// Retrieves detailed information about a specific type template including its schema and metadata. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetTypeTemplatesParams { + pub type_name: String, +} + +/// Updates an existing type template's schema definition and metadata while preserving its identifier and usage history. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct UpdateTypeTemplatesParams { + pub type_name: String, + pub type_schema: serde_json::Value, + pub description: Option, + pub change_reason: String, +} + +/// Permanently removes a type template from the workspace. No checks performed while deleting +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DeleteTypeTemplatesParams { + pub type_name: String, +} + +/// Retrieves a paginated list of all type templates in the workspace, including their schemas and metadata for type management. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListTypeTemplatesParams { + /// Number of items to be returned in each page. + pub count: Option, + /// Page number to retrieve, starting from 1. + pub page: Option, + /// If true, returns all requested items, ignoring pagination parameters page and count. + pub all: Option, +} + +/// Creates a new type template with specified schema definition, providing reusable type definitions for config validation. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateTypeTemplatesParams { + pub type_name: String, + pub type_schema: serde_json::Value, + pub description: String, + pub change_reason: String, +} + +impl SuperpositionMcpServer { + pub async fn get_type_templates_impl(&self, args: GetTypeTemplatesParams) -> Result { + let mut req = self.client.get_type_template() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.type_name(args.type_name); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&type_templates_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn update_type_templates_impl(&self, args: UpdateTypeTemplatesParams) -> Result { + let mut req = self.client.update_type_templates() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.type_name(args.type_name); + req = req.set_type_schema(Some(json_to_doc_map(args.type_schema).map_err(mcp_err)?)); + req = req.change_reason(args.change_reason); + if let Some(v) = args.description { + req = req.description(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&type_templates_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn delete_type_templates_impl(&self, args: DeleteTypeTemplatesParams) -> Result { + let mut req = self.client.delete_type_templates() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.type_name(args.type_name); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&type_templates_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn list_type_templates_impl(&self, args: ListTypeTemplatesParams) -> Result { + let mut req = self.client.get_type_templates_list() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + if let Some(v) = args.count { + req = req.count(v); + } + if let Some(v) = args.page { + req = req.page(v); + } + if let Some(v) = args.all { + req = req.all(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let items: Vec = resp.data.iter().map(|r| type_templates_to_json!(r)).collect(); + let result = serde_json::json!({ + "total_pages": resp.total_pages, + "total_items": resp.total_items, + "data": items, + }); + let json = serde_json::to_string_pretty(&result).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn create_type_templates_impl(&self, args: CreateTypeTemplatesParams) -> Result { + let mut req = self.client.create_type_templates() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.type_name(args.type_name); + req = req.set_type_schema(Some(json_to_doc_map(args.type_schema).map_err(mcp_err)?)); + req = req.description(args.description); + req = req.change_reason(args.change_reason); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&type_templates_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + +} diff --git a/crates/superposition_mcp/src/generated/variable.rs b/crates/superposition_mcp/src/generated/variable.rs new file mode 100644 index 000000000..7fdc363af --- /dev/null +++ b/crates/superposition_mcp/src/generated/variable.rs @@ -0,0 +1,156 @@ +// AUTO-GENERATED by smithy mcp-codegen — DO NOT EDIT +use rmcp::model::*; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::SuperpositionMcpServer; +use crate::helpers::*; + +/// Retrieves detailed information about a specific variable by its name. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetVariableParams { + pub name: String, +} + +/// Updates an existing variable's value, description, or tags. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct UpdateVariableParams { + pub name: String, + pub value: Option, + pub description: Option, + pub change_reason: String, +} + +/// Permanently deletes a variable from the workspace. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DeleteVariableParams { + pub name: String, +} + +/// Retrieves a paginated list of all variables in the workspace with optional filtering and sorting. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListVariableParams { + /// Number of items to be returned in each page. + pub count: Option, + /// Page number to retrieve, starting from 1. + pub page: Option, + /// If true, returns all requested items, ignoring pagination parameters page and count. + pub all: Option, + /// Filter by variable name (exact match or substring, depending on backend implementation). + pub name: Option>, + /// Filter by the user who created the variable + pub created_by: Option>, + /// Filter by the user who last modified the variable + pub last_modified_by: Option>, + /// Field to sort the results by. + pub sort_on: Option, + /// Sort order (ascending or descending). + pub sort_by: Option, +} + +/// Creates a new variable with the specified name and value. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateVariableParams { + pub name: String, + pub value: String, + pub description: String, + pub change_reason: String, +} + +impl SuperpositionMcpServer { + pub async fn get_variable_impl(&self, args: GetVariableParams) -> Result { + let mut req = self.client.get_variable() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.name(args.name); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&variable_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn update_variable_impl(&self, args: UpdateVariableParams) -> Result { + let mut req = self.client.update_variable() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.name(args.name); + req = req.change_reason(args.change_reason); + if let Some(v) = args.value { + req = req.value(v); + } + if let Some(v) = args.description { + req = req.description(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&variable_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn delete_variable_impl(&self, args: DeleteVariableParams) -> Result { + let mut req = self.client.delete_variable() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.name(args.name); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&variable_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn list_variable_impl(&self, args: ListVariableParams) -> Result { + let mut req = self.client.list_variables() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + if let Some(v) = args.count { + req = req.count(v); + } + if let Some(v) = args.page { + req = req.page(v); + } + if let Some(v) = args.all { + req = req.all(v); + } + if let Some(v) = args.name { + for item in v { + req = req.name(item); + } + } + if let Some(v) = args.created_by { + for item in v { + req = req.created_by(item); + } + } + if let Some(v) = args.last_modified_by { + for item in v { + req = req.last_modified_by(item); + } + } + if let Some(v) = args.sort_on { + req = req.sort_on(v); + } + if let Some(v) = args.sort_by { + req = req.sort_by(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let items: Vec = resp.data.iter().map(|r| variable_to_json!(r)).collect(); + let result = serde_json::json!({ + "total_pages": resp.total_pages, + "total_items": resp.total_items, + "data": items, + }); + let json = serde_json::to_string_pretty(&result).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn create_variable_impl(&self, args: CreateVariableParams) -> Result { + let mut req = self.client.create_variable() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.name(args.name); + req = req.value(args.value); + req = req.description(args.description); + req = req.change_reason(args.change_reason); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&variable_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + +} diff --git a/crates/superposition_mcp/src/generated/webhook.rs b/crates/superposition_mcp/src/generated/webhook.rs new file mode 100644 index 000000000..4cae85f35 --- /dev/null +++ b/crates/superposition_mcp/src/generated/webhook.rs @@ -0,0 +1,179 @@ +// AUTO-GENERATED by smithy mcp-codegen — DO NOT EDIT +use rmcp::model::*; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::SuperpositionMcpServer; +use crate::helpers::*; + +/// Retrieves detailed information about a specific webhook config, including its events, headers, and trigger history. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetWebhookParams { + pub name: String, +} + +/// Updates an existing webhook config, allowing modification of URL, events, headers, and other webhook properties. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct UpdateWebhookParams { + pub name: String, + pub description: Option, + pub enabled: Option, + pub url: Option, + pub method: Option, + pub version: Option, + pub custom_headers: Option, + pub events: Option>, + pub change_reason: String, +} + +/// Permanently removes a webhook config from the workspace, stopping all future event notifications to that endpoint. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DeleteWebhookParams { + pub name: String, +} + +/// Retrieves a paginated list of all webhook configs in the workspace, including their status and config details. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListWebhookParams { + /// Number of items to be returned in each page. + pub count: Option, + /// Page number to retrieve, starting from 1. + pub page: Option, + /// If true, returns all requested items, ignoring pagination parameters page and count. + pub all: Option, +} + +/// Creates a new webhook config to receive HTTP notifications when specified events occur in the system. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateWebhookParams { + pub name: String, + pub description: String, + pub enabled: bool, + pub url: String, + pub method: String, + pub version: Option, + pub custom_headers: Option, + pub events: Vec, + pub change_reason: String, +} + +/// Retrieves a webhook configuration based on a specific event type, allowing users to find which webhook is set to trigger for that event. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetWebhookByEventWebhookParams { + pub event: String, +} + +impl SuperpositionMcpServer { + pub async fn get_webhook_impl(&self, args: GetWebhookParams) -> Result { + let mut req = self.client.get_webhook() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.name(args.name); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&webhook_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn update_webhook_impl(&self, args: UpdateWebhookParams) -> Result { + let mut req = self.client.update_webhook() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.name(args.name); + req = req.change_reason(args.change_reason); + if let Some(v) = args.description { + req = req.description(v); + } + if let Some(v) = args.enabled { + req = req.enabled(v); + } + if let Some(v) = args.url { + req = req.url(v); + } + if let Some(v) = args.method { + req = req.method(v); + } + if let Some(v) = args.version { + req = req.version(v); + } + if let Some(v) = args.custom_headers { + req = req.set_custom_headers(Some(json_to_doc_map(v).map_err(mcp_err)?)); + } + if let Some(v) = args.events { + for item in v { + req = req.events(item); + } + } + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&webhook_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn delete_webhook_impl(&self, args: DeleteWebhookParams) -> Result { + let mut req = self.client.delete_webhook() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.name(args.name); + let resp = req.send().await.map_err(mcp_err)?; + let json = "Deleted successfully".to_string(); + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn list_webhook_impl(&self, args: ListWebhookParams) -> Result { + let mut req = self.client.list_webhook() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + if let Some(v) = args.count { + req = req.count(v); + } + if let Some(v) = args.page { + req = req.page(v); + } + if let Some(v) = args.all { + req = req.all(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let items: Vec = resp.data.iter().map(|r| webhook_to_json!(r)).collect(); + let result = serde_json::json!({ + "total_pages": resp.total_pages, + "total_items": resp.total_items, + "data": items, + }); + let json = serde_json::to_string_pretty(&result).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn create_webhook_impl(&self, args: CreateWebhookParams) -> Result { + let mut req = self.client.create_webhook() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.name(args.name); + req = req.description(args.description); + req = req.enabled(args.enabled); + req = req.url(args.url); + req = req.method(args.method); + for item in args.events { + req = req.events(item); + } + req = req.change_reason(args.change_reason); + if let Some(v) = args.version { + req = req.version(v); + } + if let Some(v) = args.custom_headers { + req = req.set_custom_headers(Some(json_to_doc_map(v).map_err(mcp_err)?)); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&webhook_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn get_webhook_by_event_webhook_impl(&self, args: GetWebhookByEventWebhookParams) -> Result { + let mut req = self.client.get_webhook_by_event() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.event(args.event); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&webhook_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + +} diff --git a/crates/superposition_mcp/src/generated/workspace.rs b/crates/superposition_mcp/src/generated/workspace.rs new file mode 100644 index 000000000..d60d6b2c4 --- /dev/null +++ b/crates/superposition_mcp/src/generated/workspace.rs @@ -0,0 +1,190 @@ +// AUTO-GENERATED by smithy mcp-codegen — DO NOT EDIT +use rmcp::model::*; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::SuperpositionMcpServer; +use crate::helpers::*; + +/// Retrieves detailed information about a specific workspace including its configuration and metadata. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetWorkspaceParams { + pub workspace_name: String, +} + +/// Updates an existing workspace configuration, allowing modification of admin settings, mandatory dimensions, and workspace properties. Validates config version existence if provided. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct UpdateWorkspaceParams { + pub workspace_name: String, + pub workspace_admin_email: Option, + /// To unset config version, pass "null" string. + pub config_version: Option, + pub mandatory_dimensions: Option>, + pub workspace_status: Option, + pub metrics: Option, + pub allow_experiment_self_approval: Option, + pub auto_populate_control: Option, + pub enable_context_validation: Option, + pub enable_change_reason_validation: Option, +} + +/// Retrieves a paginated list of all workspaces with optional filtering by workspace name, including their status, config details, and administrative information. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListWorkspaceParams { + /// Number of items to be returned in each page. + pub count: Option, + /// Page number to retrieve, starting from 1. + pub page: Option, + /// If true, returns all requested items, ignoring pagination parameters page and count. + pub all: Option, +} + +/// Creates a new workspace within an organisation, including database schema setup and isolated environment for config management with specified admin and settings. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateWorkspaceParams { + pub workspace_admin_email: String, + pub workspace_name: String, + pub workspace_status: Option, + pub metrics: Option, + pub allow_experiment_self_approval: Option, + pub auto_populate_control: Option, + pub enable_context_validation: Option, + pub enable_change_reason_validation: Option, +} + +/// Migrates the workspace database schema to the new version of the template +#[derive(Debug, Deserialize, JsonSchema)] +pub struct MigrateWorkspaceSchemaWorkspaceParams { + pub workspace_name: String, +} + +/// Rotates the workspace encryption key. Generates a new encryption key and re-encrypts all secrets with the new key. This is a critical operation that should be done during low-traffic periods. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct RotateWorkspaceEncryptionKeyWorkspaceParams { + pub workspace_name: String, +} + +impl SuperpositionMcpServer { + pub async fn get_workspace_impl(&self, args: GetWorkspaceParams) -> Result { + let mut req = self.client.get_workspace() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.workspace_name(args.workspace_name); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&workspace_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn update_workspace_impl(&self, args: UpdateWorkspaceParams) -> Result { + let mut req = self.client.update_workspace() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.workspace_name(args.workspace_name); + if let Some(v) = args.workspace_admin_email { + req = req.workspace_admin_email(v); + } + if let Some(v) = args.config_version { + req = req.config_version(v); + } + if let Some(v) = args.mandatory_dimensions { + for item in v { + req = req.mandatory_dimensions(item); + } + } + if let Some(v) = args.workspace_status { + req = req.workspace_status(v); + } + if let Some(v) = args.metrics { + req = req.metrics(json_to_doc(v)); + } + if let Some(v) = args.allow_experiment_self_approval { + req = req.allow_experiment_self_approval(v); + } + if let Some(v) = args.auto_populate_control { + req = req.auto_populate_control(v); + } + if let Some(v) = args.enable_context_validation { + req = req.enable_context_validation(v); + } + if let Some(v) = args.enable_change_reason_validation { + req = req.enable_change_reason_validation(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&workspace_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn list_workspace_impl(&self, args: ListWorkspaceParams) -> Result { + let mut req = self.client.list_workspace() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + if let Some(v) = args.count { + req = req.count(v); + } + if let Some(v) = args.page { + req = req.page(v); + } + if let Some(v) = args.all { + req = req.all(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let items: Vec = resp.data.iter().map(|r| workspace_to_json!(r)).collect(); + let result = serde_json::json!({ + "total_pages": resp.total_pages, + "total_items": resp.total_items, + "data": items, + }); + let json = serde_json::to_string_pretty(&result).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn create_workspace_impl(&self, args: CreateWorkspaceParams) -> Result { + let mut req = self.client.create_workspace() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.workspace_admin_email(args.workspace_admin_email); + req = req.workspace_name(args.workspace_name); + if let Some(v) = args.workspace_status { + req = req.workspace_status(v); + } + if let Some(v) = args.metrics { + req = req.metrics(json_to_doc(v)); + } + if let Some(v) = args.allow_experiment_self_approval { + req = req.allow_experiment_self_approval(v); + } + if let Some(v) = args.auto_populate_control { + req = req.auto_populate_control(v); + } + if let Some(v) = args.enable_context_validation { + req = req.enable_context_validation(v); + } + if let Some(v) = args.enable_change_reason_validation { + req = req.enable_change_reason_validation(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&workspace_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn migrate_workspace_schema_workspace_impl(&self, args: MigrateWorkspaceSchemaWorkspaceParams) -> Result { + let mut req = self.client.migrate_workspace_schema() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.workspace_name(args.workspace_name); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&workspace_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn rotate_workspace_encryption_key_workspace_impl(&self, args: RotateWorkspaceEncryptionKeyWorkspaceParams) -> Result { + let mut req = self.client.rotate_workspace_encryption_key() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + req = req.workspace_name(args.workspace_name); + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&workspace_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + +} diff --git a/crates/superposition_mcp/src/helpers.rs b/crates/superposition_mcp/src/helpers.rs new file mode 100644 index 000000000..9f9141666 --- /dev/null +++ b/crates/superposition_mcp/src/helpers.rs @@ -0,0 +1,344 @@ +use aws_smithy_types::Document; +use std::collections::HashMap; + +/// Convert a `serde_json::Value` into an `aws_smithy_types::Document`. +pub fn json_to_doc(val: serde_json::Value) -> Document { + match val { + serde_json::Value::Null => Document::Null, + serde_json::Value::Bool(b) => Document::Bool(b), + serde_json::Value::Number(n) => { + if let Some(i) = n.as_i64() { + Document::Number(aws_smithy_types::Number::NegInt(i)) + } else if let Some(f) = n.as_f64() { + Document::Number(aws_smithy_types::Number::Float(f)) + } else { + Document::Null + } + } + serde_json::Value::String(s) => Document::String(s), + serde_json::Value::Array(arr) => { + Document::Array(arr.into_iter().map(json_to_doc).collect()) + } + serde_json::Value::Object(obj) => { + let map: HashMap = + obj.into_iter().map(|(k, v)| (k, json_to_doc(v))).collect(); + Document::Object(map) + } + } +} + +/// Convert an `aws_smithy_types::Document` into a `serde_json::Value`. +pub fn doc_to_json(doc: &Document) -> serde_json::Value { + match doc { + Document::Null => serde_json::Value::Null, + Document::Bool(b) => serde_json::Value::Bool(*b), + Document::Number(n) => match n { + aws_smithy_types::Number::PosInt(i) => serde_json::Value::Number((*i).into()), + aws_smithy_types::Number::NegInt(i) => serde_json::Value::Number((*i).into()), + aws_smithy_types::Number::Float(f) => serde_json::json!(*f), + }, + Document::String(s) => serde_json::Value::String(s.clone()), + Document::Array(arr) => { + serde_json::Value::Array(arr.iter().map(doc_to_json).collect()) + } + Document::Object(obj) => { + let map: serde_json::Map = obj + .iter() + .map(|(k, v)| (k.clone(), doc_to_json(v))) + .collect(); + serde_json::Value::Object(map) + } + } +} + +/// Convert a `HashMap` into a `serde_json::Value::Object`. +pub fn doc_map_to_json(map: &HashMap) -> serde_json::Value { + let obj: serde_json::Map = map + .iter() + .map(|(k, v)| (k.clone(), doc_to_json(v))) + .collect(); + serde_json::Value::Object(obj) +} + +/// Convert a `serde_json::Value` (expected Object) into `HashMap`. +pub fn json_to_doc_map( + val: serde_json::Value, +) -> Result, String> { + match val { + serde_json::Value::Object(obj) => { + Ok(obj.into_iter().map(|(k, v)| (k, json_to_doc(v))).collect()) + } + _ => Err("Expected a JSON object".to_string()), + } +} + +/// Format a `aws_smithy_types::DateTime` as an ISO 8601 string. +pub fn format_datetime(dt: &aws_smithy_types::DateTime) -> String { + dt.fmt(aws_smithy_types::date_time::Format::DateTime) + .unwrap_or_else(|_| "unknown".to_string()) +} + +/// Create an MCP error result from a string message. +pub fn mcp_err(msg: impl std::fmt::Display) -> rmcp::ErrorData { + rmcp::ErrorData::internal_error(msg.to_string(), None) +} + +macro_rules! context_to_json { + ($r:expr) => {{ + serde_json::json!({ + "id": $r.id, + "value": $crate::helpers::doc_map_to_json(&$r.value), + "override": $crate::helpers::doc_map_to_json(&$r.r#override), + "override_id": $r.override_id, + "weight": $r.weight, + "description": $r.description, + "change_reason": $r.change_reason, + "created_at": $crate::helpers::format_datetime(&$r.created_at), + "created_by": $r.created_by, + "last_modified_at": $crate::helpers::format_datetime(&$r.last_modified_at), + "last_modified_by": $r.last_modified_by, + }) + }} +} + +macro_rules! default_config_to_json { + ($r:expr) => {{ + serde_json::json!({ + "key": $r.key, + "value": $crate::helpers::doc_to_json(&$r.value), + "schema": $crate::helpers::doc_map_to_json(&$r.schema), + "description": $r.description, + "change_reason": $r.change_reason, + "value_validation_function_name": $r.value_validation_function_name, + "value_compute_function_name": $r.value_compute_function_name, + "created_at": $crate::helpers::format_datetime(&$r.created_at), + "created_by": $r.created_by, + "last_modified_at": $crate::helpers::format_datetime(&$r.last_modified_at), + "last_modified_by": $r.last_modified_by, + }) + }} +} + +macro_rules! dimension_to_json { + ($r:expr) => {{ + serde_json::json!({ + "dimension": $r.dimension, + "position": $r.position, + "schema": $crate::helpers::doc_map_to_json(&$r.schema), + "description": $r.description, + "change_reason": $r.change_reason, + "dimension_type": format!("{:?}", $r.dimension_type), + "mandatory": $r.mandatory, + "dependency_graph": $r.dependency_graph, + "value_validation_function_name": $r.value_validation_function_name, + "value_compute_function_name": $r.value_compute_function_name, + "created_at": $crate::helpers::format_datetime(&$r.created_at), + "created_by": $r.created_by, + "last_modified_at": $crate::helpers::format_datetime(&$r.last_modified_at), + "last_modified_by": $r.last_modified_by, + }) + }} +} + +macro_rules! experiment_to_json { + ($r:expr) => {{ + serde_json::json!({ + "id": $r.id, + "name": $r.name, + "status": format!("{:?}", $r.status), + "experiment_type": format!("{:?}", $r.experiment_type), + "traffic_percentage": $r.traffic_percentage, + "context": $crate::helpers::doc_map_to_json(&$r.context), + "description": $r.description, + "change_reason": $r.change_reason, + "override_keys": $r.override_keys, + "variants": $r.variants.iter().map(|v| serde_json::json!({ + "id": v.id, + "variant_type": format!("{:?}", v.variant_type), + "overrides": $crate::helpers::doc_map_to_json(&v.overrides), + "context_id": v.context_id, + "override_id": v.override_id, + })).collect::>(), + "chosen_variant": $r.chosen_variant, + "created_at": $crate::helpers::format_datetime(&$r.created_at), + "created_by": $r.created_by, + "last_modified": $crate::helpers::format_datetime(&$r.last_modified), + "last_modified_by": $r.last_modified_by, + "experiment_group_id": $r.experiment_group_id, + }) + }} +} + +macro_rules! experiment_group_to_json { + ($r:expr) => {{ + serde_json::json!({ + "id": $r.id, + "name": $r.name, + "description": $r.description, + "change_reason": $r.change_reason, + "context": $crate::helpers::doc_map_to_json(&$r.context), + "context_hash": $r.context_hash, + "traffic_percentage": $r.traffic_percentage, + "member_experiment_ids": $r.member_experiment_ids, + "group_type": format!("{:?}", $r.group_type), + "created_at": $crate::helpers::format_datetime(&$r.created_at), + "created_by": $r.created_by, + "last_modified_at": $crate::helpers::format_datetime(&$r.last_modified_at), + "last_modified_by": $r.last_modified_by, + }) + }} +} + +macro_rules! function_to_json { + ($r:expr) => {{ + serde_json::json!({ + "function_name": $r.function_name, + "function_type": format!("{:?}", $r.function_type), + "published_code": $r.published_code, + "draft_code": $r.draft_code, + "published_runtime_version": $r.published_runtime_version.as_ref().map(|v| format!("{:?}", v)), + "draft_runtime_version": format!("{:?}", $r.draft_runtime_version), + "published_at": $r.published_at.as_ref().map($crate::helpers::format_datetime), + "draft_edited_at": $crate::helpers::format_datetime(&$r.draft_edited_at), + "published_by": $r.published_by, + "draft_edited_by": $r.draft_edited_by, + "description": $r.description, + "change_reason": $r.change_reason, + "last_modified_at": $crate::helpers::format_datetime(&$r.last_modified_at), + "last_modified_by": $r.last_modified_by, + }) + }} +} + +macro_rules! organisation_to_json { + ($r:expr) => {{ + serde_json::json!({ + "id": $r.id, + "name": $r.name, + "admin_email": $r.admin_email, + "status": format!("{:?}", $r.status), + "country_code": $r.country_code, + "contact_email": $r.contact_email, + "contact_phone": $r.contact_phone, + "sector": $r.sector, + "created_by": $r.created_by, + "created_at": $crate::helpers::format_datetime(&$r.created_at), + "updated_at": $crate::helpers::format_datetime(&$r.updated_at), + "updated_by": $r.updated_by, + }) + }} +} + +macro_rules! workspace_to_json { + ($r:expr) => {{ + serde_json::json!({ + "workspace_name": $r.workspace_name, + "organisation_id": $r.organisation_id, + "organisation_name": $r.organisation_name, + "workspace_schema_name": $r.workspace_schema_name, + "workspace_status": format!("{:?}", $r.workspace_status), + "workspace_admin_email": $r.workspace_admin_email, + "config_version": $r.config_version, + "mandatory_dimensions": $r.mandatory_dimensions, + "created_by": $r.created_by, + "created_at": $crate::helpers::format_datetime(&$r.created_at), + "last_modified_by": $r.last_modified_by, + "last_modified_at": $crate::helpers::format_datetime(&$r.last_modified_at), + }) + }} +} + +macro_rules! type_template_to_json { + ($r:expr) => {{ + serde_json::json!({ + "type_name": $r.type_name, + "type_schema": $crate::helpers::doc_map_to_json(&$r.type_schema), + "description": $r.description, + "change_reason": $r.change_reason, + "created_by": $r.created_by, + "created_at": $crate::helpers::format_datetime(&$r.created_at), + "last_modified_by": $r.last_modified_by, + "last_modified_at": $crate::helpers::format_datetime(&$r.last_modified_at), + }) + }} +} + +macro_rules! audit_log_to_json { + ($r:expr) => {{ + serde_json::json!({ + "id": $r.id, + "table_name": $r.table_name, + "user_name": $r.user_name, + "timestamp": $crate::helpers::format_datetime(&$r.timestamp), + "action": format!("{:?}", $r.action), + "original_data": $r.original_data.as_ref().map($crate::helpers::doc_to_json), + "new_data": $r.new_data.as_ref().map($crate::helpers::doc_to_json), + "query": $r.query, + }) + }} +} + +macro_rules! variable_to_json { + ($r:expr) => {{ + serde_json::json!({ + "name": $r.name, + "value": $r.value, + "description": $r.description, + "change_reason": $r.change_reason, + "created_by": $r.created_by, + "created_at": $crate::helpers::format_datetime(&$r.created_at), + "last_modified_by": $r.last_modified_by, + "last_modified_at": $crate::helpers::format_datetime(&$r.last_modified_at), + }) + }} +} + +macro_rules! webhook_to_json { + ($r:expr) => {{ + serde_json::json!({ + "name": $r.name, + "description": $r.description, + "enabled": $r.enabled, + "url": $r.url, + "method": format!("{:?}", $r.method), + "version": format!("{:?}", $r.version), + "events": $r.events, + "max_retries": $r.max_retries, + "custom_headers": $r.custom_headers.as_ref().map($crate::helpers::doc_map_to_json), + "last_triggered_at": $r.last_triggered_at.as_ref().map($crate::helpers::format_datetime), + "change_reason": $r.change_reason, + "created_by": $r.created_by, + "created_at": $crate::helpers::format_datetime(&$r.created_at), + "last_modified_by": $r.last_modified_by, + "last_modified_at": $crate::helpers::format_datetime(&$r.last_modified_at), + }) + }} +} + +macro_rules! secret_to_json { + ($r:expr) => {{ + serde_json::json!({ + "name": $r.name, + "description": $r.description, + "change_reason": $r.change_reason, + "created_by": $r.created_by, + "created_at": $crate::helpers::format_datetime(&$r.created_at), + "last_modified_by": $r.last_modified_by, + "last_modified_at": $crate::helpers::format_datetime(&$r.last_modified_at), + }) + }} +} + +pub(crate) use audit_log_to_json; +pub(crate) use context_to_json; +pub(crate) use default_config_to_json; +pub(crate) use dimension_to_json; +pub(crate) use experiment_group_to_json; +pub(crate) use experiment_to_json; +pub(crate) use function_to_json; +pub(crate) use organisation_to_json; +pub(crate) use secret_to_json; +pub(crate) use type_template_to_json; +pub(crate) use variable_to_json; +pub(crate) use webhook_to_json; +pub(crate) use workspace_to_json; diff --git a/crates/superposition_mcp/src/lib.rs b/crates/superposition_mcp/src/lib.rs new file mode 100644 index 000000000..0bdb23d07 --- /dev/null +++ b/crates/superposition_mcp/src/lib.rs @@ -0,0 +1,10 @@ +pub mod config; +pub mod helpers; +pub mod server; +pub mod tools; + +#[cfg(feature = "actix")] +pub mod actix; + +pub use config::McpServerConfig; +pub use server::SuperpositionMcpServer; diff --git a/crates/superposition_mcp/src/main.rs b/crates/superposition_mcp/src/main.rs new file mode 100644 index 000000000..995610a3b --- /dev/null +++ b/crates/superposition_mcp/src/main.rs @@ -0,0 +1,30 @@ +use rmcp::ServiceExt; +use superposition_mcp::{McpServerConfig, SuperpositionMcpServer}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive(tracing::Level::INFO.into()), + ) + .init(); + + let config = McpServerConfig::from_env()?; + tracing::info!( + endpoint = %config.endpoint_url, + workspace = %config.workspace_id, + org = %config.org_id, + "Starting Superposition MCP server" + ); + + let server = SuperpositionMcpServer::new(config); + let service = server + .serve(rmcp::transport::io::stdio()) + .await + .map_err(|e| anyhow::anyhow!("Failed to start MCP server: {e}"))?; + + service.waiting().await?; + Ok(()) +} diff --git a/crates/superposition_mcp/src/server.rs b/crates/superposition_mcp/src/server.rs new file mode 100644 index 000000000..99dd46924 --- /dev/null +++ b/crates/superposition_mcp/src/server.rs @@ -0,0 +1,955 @@ +use rmcp::handler::server::tool::ToolRouter; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::*; +use rmcp::{ServerHandler, tool, tool_router}; + +use crate::config::McpServerConfig; +use crate::tools::{ + audit_log::*, config::*, context::*, default_config::*, dimension::*, experiment::*, + experiment_group::*, function::*, organisation::*, secret::*, type_template::*, + variable::*, webhook::*, workspace::*, +}; + +/// The Superposition MCP Server — exposes all Superposition API operations as MCP tools. +#[derive(Clone)] +pub struct SuperpositionMcpServer { + pub(crate) client: superposition_sdk::Client, + /// Server configuration (public for testing) + pub config: McpServerConfig, + #[allow(dead_code)] + tool_router: ToolRouter, +} + +impl SuperpositionMcpServer { + pub fn new(config: McpServerConfig) -> Self { + let client = config.build_sdk_client(); + let tool_router = Self::tool_router(); + Self { + client, + config, + tool_router, + } + } +} + +#[tool_router] +impl SuperpositionMcpServer { + // ===== Configuration Management ===== + #[tool( + name = "config.get", + description = "Retrieves config data with context evaluation, including applicable contexts, overrides, and default values based on provided conditions." + )] + async fn config_get( + &self, + Parameters(args): Parameters, + ) -> Result { + self.get_config_impl(args).await + } + + #[tool( + name = "config.resolve", + description = "Resolves and merges config values based on context conditions, applying overrides and merge strategies to produce the final configuration." + )] + async fn config_resolve( + &self, + Parameters(args): Parameters, + ) -> Result { + self.resolve_config_impl(args).await + } + + #[tool( + name = "config.resolve_with_identifier", + description = "Resolves and merges config values like config.resolve, but also accepts an identifier (e.g. user ID) for cohort-based resolution and returns an audit_id." + )] + async fn config_resolve_with_identifier( + &self, + Parameters(args): Parameters, + ) -> Result { + self.resolve_config_with_identifier_impl(args).await + } + + #[tool( + name = "config.get_fast", + description = "Retrieves the latest raw config with no processing for high-performance access." + )] + async fn config_get_fast(&self) -> Result { + self.get_config_fast_impl().await + } + + #[tool( + name = "config.get_toml", + description = "Retrieves the full config in TOML format including default configs, dimensions, and overrides." + )] + async fn config_get_toml(&self) -> Result { + self.get_config_toml_impl().await + } + + #[tool( + name = "config.get_json", + description = "Retrieves the full config in JSON format including default configs, dimensions, and overrides." + )] + async fn config_get_json(&self) -> Result { + self.get_config_json_impl().await + } + + #[tool( + name = "config.get_version", + description = "Retrieves a specific config version along with its metadata for audit and rollback purposes." + )] + async fn config_get_version( + &self, + Parameters(args): Parameters, + ) -> Result { + self.get_version_impl(args).await + } + + #[tool( + name = "config.list_versions", + description = "Retrieves a paginated list of config versions with metadata, hash values, and creation timestamps." + )] + async fn config_list_versions( + &self, + Parameters(args): Parameters, + ) -> Result { + self.list_versions_impl(args).await + } + + // ===== Default Config ===== + #[tool( + name = "default_config.create", + description = "Creates a new default config entry with key, value, JSON schema, and metadata. Default configs serve as fallback values when no context matches." + )] + async fn default_config_create( + &self, + Parameters(args): Parameters, + ) -> Result { + self.create_default_config_impl(args).await + } + + #[tool( + name = "default_config.get", + description = "Retrieves a specific default config entry by its key, including its value, schema, and metadata." + )] + async fn default_config_get( + &self, + Parameters(args): Parameters, + ) -> Result { + self.get_default_config_impl(args).await + } + + #[tool( + name = "default_config.list", + description = "Retrieves a paginated list of all default config entries in the workspace." + )] + async fn default_config_list( + &self, + Parameters(args): Parameters, + ) -> Result { + self.list_default_configs_impl(args).await + } + + #[tool( + name = "default_config.update", + description = "Updates an existing default config entry's value, schema, or description." + )] + async fn default_config_update( + &self, + Parameters(args): Parameters, + ) -> Result { + self.update_default_config_impl(args).await + } + + #[tool( + name = "default_config.delete", + description = "Permanently removes a default config entry from the workspace." + )] + async fn default_config_delete( + &self, + Parameters(args): Parameters, + ) -> Result { + self.delete_default_config_impl(args).await + } + + // ===== Dimensions ===== + #[tool( + name = "dimension.create", + description = "Creates a new dimension with a JSON schema. Dimensions define categorical attributes used for context-based config management." + )] + async fn dimension_create( + &self, + Parameters(args): Parameters, + ) -> Result { + self.create_dimension_impl(args).await + } + + #[tool( + name = "dimension.get", + description = "Retrieves detailed information about a specific dimension including its schema and dependency graph." + )] + async fn dimension_get( + &self, + Parameters(args): Parameters, + ) -> Result { + self.get_dimension_impl(args).await + } + + #[tool( + name = "dimension.list", + description = "Retrieves a paginated list of all dimensions in the workspace." + )] + async fn dimension_list( + &self, + Parameters(args): Parameters, + ) -> Result { + self.list_dimensions_impl(args).await + } + + #[tool( + name = "dimension.update", + description = "Updates an existing dimension's schema, position, or function mappings." + )] + async fn dimension_update( + &self, + Parameters(args): Parameters, + ) -> Result { + self.update_dimension_impl(args).await + } + + #[tool( + name = "dimension.delete", + description = "Permanently removes a dimension from the workspace." + )] + async fn dimension_delete( + &self, + Parameters(args): Parameters, + ) -> Result { + self.delete_dimension_impl(args).await + } + + // ===== Contexts ===== + #[tool( + name = "context.create", + description = "Creates a new context with conditions and overrides. Contexts define conditional rules for config management." + )] + async fn context_create( + &self, + Parameters(args): Parameters, + ) -> Result { + self.create_context_impl(args).await + } + + #[tool( + name = "context.get", + description = "Retrieves detailed information about a specific context by its ID." + )] + async fn context_get( + &self, + Parameters(args): Parameters, + ) -> Result { + self.get_context_impl(args).await + } + + #[tool( + name = "context.list", + description = "Retrieves a paginated list of contexts with filtering and sorting support." + )] + async fn context_list( + &self, + Parameters(args): Parameters, + ) -> Result { + self.list_contexts_impl(args).await + } + + #[tool( + name = "context.delete", + description = "Permanently removes a context from the workspace." + )] + async fn context_delete( + &self, + Parameters(args): Parameters, + ) -> Result { + self.delete_context_impl(args).await + } + + #[tool( + name = "context.update_override", + description = "Updates the overrides for an existing context while maintaining the context's conditions." + )] + async fn context_update_override( + &self, + Parameters(args): Parameters, + ) -> Result { + self.update_override_impl(args).await + } + + #[tool( + name = "context.move", + description = "Moves a context to a new condition. If a context with the new condition already exists, it merges the override." + )] + async fn context_move( + &self, + Parameters(args): Parameters, + ) -> Result { + self.move_context_impl(args).await + } + + #[tool( + name = "context.get_by_condition", + description = "Retrieves context information by matching against provided conditions." + )] + async fn context_get_by_condition( + &self, + Parameters(args): Parameters, + ) -> Result { + self.get_context_from_condition_impl(args).await + } + + #[tool( + name = "context.validate", + description = "Validates a context's conditions without creating it. Returns success if the context is valid, or an error describing why it is invalid." + )] + async fn context_validate( + &self, + Parameters(args): Parameters, + ) -> Result { + self.validate_context_impl(args).await + } + + #[tool( + name = "context.weight_recompute", + description = "Recalculates priority weights for all contexts in the workspace." + )] + async fn context_weight_recompute( + &self, + Parameters(args): Parameters, + ) -> Result { + self.weight_recompute_impl(args).await + } + + #[tool( + name = "context.bulk_operation", + description = "Executes multiple context operations (PUT, REPLACE, DELETE, MOVE) in a single batch." + )] + async fn context_bulk_operation( + &self, + Parameters(args): Parameters, + ) -> Result { + self.bulk_operation_impl(args).await + } + + // ===== Experiments ===== + #[tool( + name = "experiment.create", + description = "Creates a new A/B experiment with variants, context conditions, and optional metrics." + )] + async fn experiment_create( + &self, + Parameters(args): Parameters, + ) -> Result { + self.create_experiment_impl(args).await + } + + #[tool( + name = "experiment.get", + description = "Retrieves detailed information about a specific experiment including variants, status, and metrics." + )] + async fn experiment_get( + &self, + Parameters(args): Parameters, + ) -> Result { + self.get_experiment_impl(args).await + } + + #[tool( + name = "experiment.list", + description = "Retrieves a paginated list of experiments with filtering by status, date, name, and group." + )] + async fn experiment_list( + &self, + Parameters(args): Parameters, + ) -> Result { + self.list_experiments_impl(args).await + } + + #[tool( + name = "experiment.update_overrides", + description = "Updates the overrides for specific variants within an experiment." + )] + async fn experiment_update_overrides( + &self, + Parameters(args): Parameters, + ) -> Result { + self.update_experiment_overrides_impl(args).await + } + + #[tool( + name = "experiment.conclude", + description = "Concludes an in-progress experiment by selecting a winning variant." + )] + async fn experiment_conclude( + &self, + Parameters(args): Parameters, + ) -> Result { + self.conclude_experiment_impl(args).await + } + + #[tool( + name = "experiment.discard", + description = "Discards an experiment without selecting a winner." + )] + async fn experiment_discard( + &self, + Parameters(args): Parameters, + ) -> Result { + self.discard_experiment_impl(args).await + } + + #[tool( + name = "experiment.ramp", + description = "Adjusts the traffic percentage allocation for an in-progress experiment." + )] + async fn experiment_ramp( + &self, + Parameters(args): Parameters, + ) -> Result { + self.ramp_experiment_impl(args).await + } + + #[tool( + name = "experiment.pause", + description = "Temporarily pauses an in-progress experiment, preserving its config for later resumption." + )] + async fn experiment_pause( + &self, + Parameters(args): Parameters, + ) -> Result { + self.pause_experiment_impl(args).await + } + + #[tool( + name = "experiment.resume", + description = "Resumes a previously paused experiment, restoring its in-progress state." + )] + async fn experiment_resume( + &self, + Parameters(args): Parameters, + ) -> Result { + self.resume_experiment_impl(args).await + } + + #[tool( + name = "experiment.applicable_variants", + description = "Determines which experiment variants are applicable to a given context and identifier." + )] + async fn experiment_applicable_variants( + &self, + Parameters(args): Parameters, + ) -> Result { + self.applicable_variants_impl(args).await + } + + // ===== Experiment Groups ===== + #[tool( + name = "experiment_group.create", + description = "Creates a new experiment group for managing multiple experiments together." + )] + async fn experiment_group_create( + &self, + Parameters(args): Parameters, + ) -> Result { + self.create_experiment_group_impl(args).await + } + + #[tool( + name = "experiment_group.get", + description = "Retrieves an experiment group by its ID." + )] + async fn experiment_group_get( + &self, + Parameters(args): Parameters, + ) -> Result { + self.get_experiment_group_impl(args).await + } + + #[tool( + name = "experiment_group.list", + description = "Lists experiment groups with filtering and pagination." + )] + async fn experiment_group_list( + &self, + Parameters(args): Parameters, + ) -> Result { + self.list_experiment_groups_impl(args).await + } + + #[tool( + name = "experiment_group.update", + description = "Updates an experiment group's description or traffic percentage." + )] + async fn experiment_group_update( + &self, + Parameters(args): Parameters, + ) -> Result { + self.update_experiment_group_impl(args).await + } + + #[tool( + name = "experiment_group.delete", + description = "Deletes an experiment group." + )] + async fn experiment_group_delete( + &self, + Parameters(args): Parameters, + ) -> Result { + self.delete_experiment_group_impl(args).await + } + + #[tool( + name = "experiment_group.add_members", + description = "Adds experiments to an existing experiment group." + )] + async fn experiment_group_add_members( + &self, + Parameters(args): Parameters, + ) -> Result { + self.add_members_to_group_impl(args).await + } + + #[tool( + name = "experiment_group.remove_members", + description = "Removes experiments from an existing experiment group." + )] + async fn experiment_group_remove_members( + &self, + Parameters(args): Parameters, + ) -> Result { + self.remove_members_from_group_impl(args).await + } + + // ===== Functions ===== + #[tool( + name = "function.create", + description = "Creates a new custom function for value validation, value compute, context validation, or change reason validation." + )] + async fn function_create( + &self, + Parameters(args): Parameters, + ) -> Result { + self.create_function_impl(args).await + } + + #[tool( + name = "function.get", + description = "Retrieves detailed information about a function including its published and draft versions." + )] + async fn function_get( + &self, + Parameters(args): Parameters, + ) -> Result { + self.get_function_impl(args).await + } + + #[tool( + name = "function.list", + description = "Retrieves a paginated list of all functions in the workspace." + )] + async fn function_list( + &self, + Parameters(args): Parameters, + ) -> Result { + self.list_functions_impl(args).await + } + + #[tool( + name = "function.update", + description = "Updates the draft version of a function with new code or description." + )] + async fn function_update( + &self, + Parameters(args): Parameters, + ) -> Result { + self.update_function_impl(args).await + } + + #[tool( + name = "function.delete", + description = "Permanently removes a function from the workspace." + )] + async fn function_delete( + &self, + Parameters(args): Parameters, + ) -> Result { + self.delete_function_impl(args).await + } + + #[tool( + name = "function.publish", + description = "Publishes the draft version of a function, making it the active version." + )] + async fn function_publish( + &self, + Parameters(args): Parameters, + ) -> Result { + self.publish_function_impl(args).await + } + + #[tool( + name = "function.test", + description = "Executes a function in test mode with provided input parameters." + )] + async fn function_test( + &self, + Parameters(args): Parameters, + ) -> Result { + self.test_function_impl(args).await + } + + // ===== Type Templates ===== + #[tool( + name = "type_template.create", + description = "Creates a new type template with a JSON schema definition." + )] + async fn type_template_create( + &self, + Parameters(args): Parameters, + ) -> Result { + self.create_type_template_impl(args).await + } + + #[tool( + name = "type_template.get", + description = "Retrieves a type template by name including its schema and metadata." + )] + async fn type_template_get( + &self, + Parameters(args): Parameters, + ) -> Result { + self.get_type_template_impl(args).await + } + + #[tool( + name = "type_template.list", + description = "Retrieves a paginated list of all type templates in the workspace." + )] + async fn type_template_list( + &self, + Parameters(args): Parameters, + ) -> Result { + self.list_type_templates_impl(args).await + } + + #[tool( + name = "type_template.update", + description = "Updates an existing type template's schema definition." + )] + async fn type_template_update( + &self, + Parameters(args): Parameters, + ) -> Result { + self.update_type_template_impl(args).await + } + + #[tool( + name = "type_template.delete", + description = "Permanently removes a type template from the workspace." + )] + async fn type_template_delete( + &self, + Parameters(args): Parameters, + ) -> Result { + self.delete_type_template_impl(args).await + } + + // ===== Organisations ===== + #[tool( + name = "organisation.create", + description = "Creates a new organisation with a name and admin email." + )] + async fn organisation_create( + &self, + Parameters(args): Parameters, + ) -> Result { + self.create_organisation_impl(args).await + } + + #[tool( + name = "organisation.get", + description = "Retrieves detailed information about a specific organisation." + )] + async fn organisation_get( + &self, + Parameters(args): Parameters, + ) -> Result { + self.get_organisation_impl(args).await + } + + #[tool( + name = "organisation.list", + description = "Retrieves a paginated list of all organisations." + )] + async fn organisation_list( + &self, + Parameters(args): Parameters, + ) -> Result { + self.list_organisations_impl(args).await + } + + #[tool( + name = "organisation.update", + description = "Updates an organisation's contact details, status, or admin email." + )] + async fn organisation_update( + &self, + Parameters(args): Parameters, + ) -> Result { + self.update_organisation_impl(args).await + } + + // ===== Workspaces ===== + #[tool( + name = "workspace.create", + description = "Creates a new workspace within an organisation with isolated config management." + )] + async fn workspace_create( + &self, + Parameters(args): Parameters, + ) -> Result { + self.create_workspace_impl(args).await + } + + #[tool( + name = "workspace.get", + description = "Retrieves detailed information about a specific workspace." + )] + async fn workspace_get( + &self, + Parameters(args): Parameters, + ) -> Result { + self.get_workspace_impl(args).await + } + + #[tool( + name = "workspace.list", + description = "Retrieves a paginated list of all workspaces." + )] + async fn workspace_list( + &self, + Parameters(args): Parameters, + ) -> Result { + self.list_workspaces_impl(args).await + } + + #[tool( + name = "workspace.update", + description = "Updates an existing workspace's admin email, status, or mandatory dimensions." + )] + async fn workspace_update( + &self, + Parameters(args): Parameters, + ) -> Result { + self.update_workspace_impl(args).await + } + + // ===== Audit Logs ===== + #[tool( + name = "audit_log.list", + description = "Retrieves a paginated list of audit logs with filtering by date, table, action, and username." + )] + async fn audit_log_list( + &self, + Parameters(args): Parameters, + ) -> Result { + self.list_audit_logs_impl(args).await + } + + // ===== Variables ===== + #[tool( + name = "variable.create", + description = "Creates a new key-value variable." + )] + async fn variable_create( + &self, + Parameters(args): Parameters, + ) -> Result { + self.create_variable_impl(args).await + } + + #[tool( + name = "variable.get", + description = "Retrieves a specific variable by name." + )] + async fn variable_get( + &self, + Parameters(args): Parameters, + ) -> Result { + self.get_variable_impl(args).await + } + + #[tool( + name = "variable.list", + description = "Retrieves a paginated list of all variables in the workspace." + )] + async fn variable_list( + &self, + Parameters(args): Parameters, + ) -> Result { + self.list_variables_impl(args).await + } + + #[tool( + name = "variable.update", + description = "Updates an existing variable's value or description." + )] + async fn variable_update( + &self, + Parameters(args): Parameters, + ) -> Result { + self.update_variable_impl(args).await + } + + #[tool( + name = "variable.delete", + description = "Permanently deletes a variable from the workspace." + )] + async fn variable_delete( + &self, + Parameters(args): Parameters, + ) -> Result { + self.delete_variable_impl(args).await + } + + // ===== Webhooks ===== + #[tool( + name = "webhook.create", + description = "Creates a new webhook to receive HTTP notifications on specified events." + )] + async fn webhook_create( + &self, + Parameters(args): Parameters, + ) -> Result { + self.create_webhook_impl(args).await + } + + #[tool( + name = "webhook.get", + description = "Retrieves a specific webhook's configuration." + )] + async fn webhook_get( + &self, + Parameters(args): Parameters, + ) -> Result { + self.get_webhook_impl(args).await + } + + #[tool( + name = "webhook.list", + description = "Retrieves a paginated list of all webhooks in the workspace." + )] + async fn webhook_list( + &self, + Parameters(args): Parameters, + ) -> Result { + self.list_webhooks_impl(args).await + } + + #[tool( + name = "webhook.update", + description = "Updates an existing webhook's URL, events, headers, or other properties." + )] + async fn webhook_update( + &self, + Parameters(args): Parameters, + ) -> Result { + self.update_webhook_impl(args).await + } + + #[tool( + name = "webhook.delete", + description = "Permanently removes a webhook from the workspace." + )] + async fn webhook_delete( + &self, + Parameters(args): Parameters, + ) -> Result { + self.delete_webhook_impl(args).await + } + + #[tool( + name = "webhook.get_by_event", + description = "Retrieves the webhook configured for a specific event type." + )] + async fn webhook_get_by_event( + &self, + Parameters(args): Parameters, + ) -> Result { + self.get_webhook_by_event_impl(args).await + } + + // ===== Secrets ===== + #[tool( + name = "secret.create", + description = "Creates a new encrypted secret with the specified name and value." + )] + async fn secret_create( + &self, + Parameters(args): Parameters, + ) -> Result { + self.create_secret_impl(args).await + } + + #[tool( + name = "secret.get", + description = "Retrieves secret metadata by name (values are always masked)." + )] + async fn secret_get( + &self, + Parameters(args): Parameters, + ) -> Result { + self.get_secret_impl(args).await + } + + #[tool( + name = "secret.list", + description = "Retrieves a paginated list of all secrets in the workspace (values are masked)." + )] + async fn secret_list( + &self, + Parameters(args): Parameters, + ) -> Result { + self.list_secrets_impl(args).await + } + + #[tool( + name = "secret.update", + description = "Updates a secret's value or description. The value is re-encrypted with the current key." + )] + async fn secret_update( + &self, + Parameters(args): Parameters, + ) -> Result { + self.update_secret_impl(args).await + } + + #[tool( + name = "secret.delete", + description = "Permanently deletes a secret from the workspace." + )] + async fn secret_delete( + &self, + Parameters(args): Parameters, + ) -> Result { + self.delete_secret_impl(args).await + } +} + +impl ServerHandler for SuperpositionMcpServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_instructions( + "Superposition MCP Server — provides tools for managing feature flags, \ + A/B experiments, configuration contexts, dimensions, and more via the \ + Superposition platform." + .to_string(), + ) + } +} diff --git a/crates/superposition_mcp/src/tools.rs b/crates/superposition_mcp/src/tools.rs new file mode 100644 index 000000000..60bd5dd11 --- /dev/null +++ b/crates/superposition_mcp/src/tools.rs @@ -0,0 +1,14 @@ +pub mod audit_log; +pub mod config; +pub mod context; +pub mod default_config; +pub mod dimension; +pub mod experiment; +pub mod experiment_group; +pub mod function; +pub mod organisation; +pub mod secret; +pub mod type_template; +pub mod variable; +pub mod webhook; +pub mod workspace; diff --git a/crates/superposition_mcp/src/tools/audit_log.rs b/crates/superposition_mcp/src/tools/audit_log.rs new file mode 100644 index 000000000..41d790d2a --- /dev/null +++ b/crates/superposition_mcp/src/tools/audit_log.rs @@ -0,0 +1,86 @@ +use rmcp::model::*; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::SuperpositionMcpServer; +use crate::helpers::*; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListAuditLogsParams { + /// Number of items per page + pub count: Option, + /// Page number (starting from 1) + pub page: Option, + /// If true, returns all items ignoring pagination + pub all: Option, + /// Filter by table names + pub tables: Option>, + /// Filter by actions: INSERT, UPDATE, DELETE + pub action: Option>, + /// Filter by username + pub username: Option, + /// Sort order: asc or desc + pub sort_by: Option, + /// Start date filter (ISO 8601) + pub from_date: Option, + /// End date filter (ISO 8601) + pub to_date: Option, +} + +impl SuperpositionMcpServer { + pub async fn list_audit_logs_impl( + &self, + args: ListAuditLogsParams, + ) -> Result { + let mut req = self + .client + .list_audit_logs() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + if let Some(c) = args.count { + req = req.count(c); + } + if let Some(p) = args.page { + req = req.page(p); + } + if let Some(a) = args.all { + req = req.all(a); + } + if let Some(tables) = args.tables { + for t in tables { + req = req.tables(t); + } + } + if let Some(actions) = args.action { + for a in actions { + let act = match a.to_uppercase().as_str() { + "INSERT" => superposition_sdk::types::AuditAction::Insert, + "UPDATE" => superposition_sdk::types::AuditAction::Update, + "DELETE" => superposition_sdk::types::AuditAction::Delete, + _ => continue, + }; + req = req.action(act); + } + } + if let Some(u) = args.username { + req = req.username(u); + } + if let Some(sb) = args.sort_by { + let sort = match sb.as_str() { + "asc" => superposition_sdk::types::SortBy::Asc, + _ => superposition_sdk::types::SortBy::Desc, + }; + req = req.sort_by(sort); + } + let resp = req.send().await.map_err(mcp_err)?; + let items: Vec = + resp.data.iter().map(|r| audit_log_to_json!(r)).collect(); + let result = serde_json::json!({ + "total_pages": resp.total_pages, + "total_items": resp.total_items, + "data": items, + }); + let json = serde_json::to_string_pretty(&result).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } +} diff --git a/crates/superposition_mcp/src/tools/config.rs b/crates/superposition_mcp/src/tools/config.rs new file mode 100644 index 000000000..a5dc9bf94 --- /dev/null +++ b/crates/superposition_mcp/src/tools/config.rs @@ -0,0 +1,246 @@ +use crate::SuperpositionMcpServer; +use crate::helpers::*; +use rmcp::model::*; +use schemars::JsonSchema; +use serde::Deserialize; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetConfigParams { + pub context: Option, + pub prefix: Option>, + pub version: Option, +} +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ResolveConfigParams { + pub context: Option, + pub prefix: Option>, + pub version: Option, + pub show_reasoning: Option, + pub merge_strategy: Option, + pub context_id: Option, + pub resolve_remote: Option, +} +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetVersionParams { + pub id: String, +} +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListVersionsParams { + pub count: Option, + pub page: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ResolveConfigWithIdentifierParams { + pub context: Option, + pub prefix: Option>, + pub version: Option, + pub show_reasoning: Option, + pub merge_strategy: Option, + pub context_id: Option, + pub resolve_remote: Option, + /// Identifier for config resolution (e.g. user ID, session ID) + pub identifier: Option, +} + +impl SuperpositionMcpServer { + pub async fn get_config_impl( + &self, + args: GetConfigParams, + ) -> Result { + let mut req = self + .client + .get_config() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + if let Some(ctx) = args.context { + req = req.set_context(Some(json_to_doc_map(ctx).map_err(mcp_err)?)); + } + if let Some(prefix) = args.prefix { + for p in prefix { + req = req.prefix(p); + } + } + if let Some(v) = args.version { + req = req.version(v); + } + let resp = req.send().await.map_err(mcp_err)?; + let result = serde_json::json!({ + "contexts": resp.contexts.iter().map(|c| serde_json::json!({"id": c.id, "condition": doc_map_to_json(&c.condition), "priority": c.priority, "weight": c.weight, "override_with_keys": c.override_with_keys})).collect::>(), + "overrides": resp.overrides.iter().map(|(k, v)| (k.clone(), doc_map_to_json(v))).collect::>(), + "default_configs": doc_map_to_json(&resp.default_configs), + "version": resp.version, + "last_modified": format_datetime(&resp.last_modified), + }); + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&result).map_err(mcp_err)?, + )])) + } + pub async fn resolve_config_impl( + &self, + args: ResolveConfigParams, + ) -> Result { + let mut req = self + .client + .get_resolved_config() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + if let Some(ctx) = args.context { + req = req.set_context(Some(json_to_doc_map(ctx).map_err(mcp_err)?)); + } + if let Some(prefix) = args.prefix { + for p in prefix { + req = req.prefix(p); + } + } + if let Some(v) = args.version { + req = req.version(v); + } + if let Some(sr) = args.show_reasoning { + req = req.show_reasoning(sr); + } + if let Some(ms) = args.merge_strategy { + req = req.merge_strategy(if ms.to_uppercase() == "REPLACE" { + superposition_sdk::types::MergeStrategy::Replace + } else { + superposition_sdk::types::MergeStrategy::Merge + }); + } + if let Some(cid) = args.context_id { + req = req.context_id(cid); + } + if let Some(rr) = args.resolve_remote { + req = req.resolve_remote(rr); + } + let resp = req.send().await.map_err(mcp_err)?; + let result = serde_json::json!({"config": doc_to_json(&resp.config), "version": resp.version, "last_modified": format_datetime(&resp.last_modified)}); + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&result).map_err(mcp_err)?, + )])) + } + pub async fn resolve_config_with_identifier_impl( + &self, + args: ResolveConfigWithIdentifierParams, + ) -> Result { + let mut req = self + .client + .get_resolved_config_with_identifier() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + if let Some(ctx) = args.context { + req = req.set_context(Some(json_to_doc_map(ctx).map_err(mcp_err)?)); + } + if let Some(prefix) = args.prefix { + for p in prefix { + req = req.prefix(p); + } + } + if let Some(v) = args.version { + req = req.version(v); + } + if let Some(sr) = args.show_reasoning { + req = req.show_reasoning(sr); + } + if let Some(ms) = args.merge_strategy { + req = req.merge_strategy(if ms.to_uppercase() == "REPLACE" { + superposition_sdk::types::MergeStrategy::Replace + } else { + superposition_sdk::types::MergeStrategy::Merge + }); + } + if let Some(cid) = args.context_id { + req = req.context_id(cid); + } + if let Some(rr) = args.resolve_remote { + req = req.resolve_remote(rr); + } + if let Some(id) = args.identifier { + req = req.identifier(id); + } + let resp = req.send().await.map_err(mcp_err)?; + let result = serde_json::json!({"config": doc_to_json(&resp.config), "version": resp.version, "last_modified": format_datetime(&resp.last_modified), "audit_id": resp.audit_id}); + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&result).map_err(mcp_err)?, + )])) + } + pub async fn get_config_fast_impl(&self) -> Result { + let resp = self + .client + .get_config_fast() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .send() + .await + .map_err(mcp_err)?; + let result = serde_json::json!({"config": resp.config.as_ref().map(doc_to_json), "version": resp.version, "last_modified": resp.last_modified.as_ref().map(format_datetime)}); + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&result).map_err(mcp_err)?, + )])) + } + pub async fn get_config_toml_impl(&self) -> Result { + let resp = self + .client + .get_config_toml() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .send() + .await + .map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text( + resp.toml_config, + )])) + } + pub async fn get_config_json_impl(&self) -> Result { + let resp = self + .client + .get_config_json() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .send() + .await + .map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text( + resp.json_config, + )])) + } + pub async fn get_version_impl( + &self, + args: GetVersionParams, + ) -> Result { + let resp = self + .client + .get_version() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .id(args.id) + .send() + .await + .map_err(mcp_err)?; + let result = serde_json::json!({"id": resp.id, "config": doc_to_json(&resp.config), "config_hash": resp.config_hash, "created_at": format_datetime(&resp.created_at), "description": resp.description, "tags": resp.tags}); + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&result).map_err(mcp_err)?, + )])) + } + pub async fn list_versions_impl( + &self, + args: ListVersionsParams, + ) -> Result { + let mut req = self + .client + .list_versions() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + if let Some(c) = args.count { + req = req.count(c); + } + if let Some(p) = args.page { + req = req.page(p); + } + let resp = req.send().await.map_err(mcp_err)?; + let items: Vec = resp.data.iter().map(|v| serde_json::json!({"id": v.id, "config": doc_to_json(&v.config), "created_at": format_datetime(&v.created_at), "description": v.description, "tags": v.tags})).collect(); + let result = serde_json::json!({"total_pages": resp.total_pages, "total_items": resp.total_items, "data": items}); + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&result).map_err(mcp_err)?, + )])) + } +} diff --git a/crates/superposition_mcp/src/tools/context.rs b/crates/superposition_mcp/src/tools/context.rs new file mode 100644 index 000000000..7a0d45412 --- /dev/null +++ b/crates/superposition_mcp/src/tools/context.rs @@ -0,0 +1,373 @@ +use rmcp::model::*; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::SuperpositionMcpServer; +use crate::helpers::*; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateContextParams { + /// Condition map: dimension names to their criteria values + pub context: serde_json::Value, + /// Override map: config keys to override values + pub r#override: serde_json::Value, + /// Reason for this change + pub change_reason: String, + /// Optional description + pub description: Option, + /// Optional config tags header + pub config_tags: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetContextParams { + /// Context ID to retrieve + pub id: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListContextsParams { + /// Number of items per page + pub count: Option, + /// Page number (starting from 1) + pub page: Option, + /// If true, returns all items ignoring pagination + pub all: Option, + /// Filter by config key prefix + pub prefix: Option>, + /// Sort field: last_modified_at, created_at, or weight + pub sort_on: Option, + /// Sort order: asc or desc + pub sort_by: Option, + /// Full-text search in context conditions + pub plaintext: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DeleteContextParams { + /// Context ID to delete + pub id: String, + /// Optional config tags header + pub config_tags: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct UpdateOverrideParams { + /// Context identifier — either {"id": "..."} or {"context": {...condition...}} + pub context: serde_json::Value, + /// Override map: config keys to override values + pub r#override: serde_json::Value, + /// Reason for this change + pub change_reason: String, + /// Optional description + pub description: Option, + /// Optional config tags header + pub config_tags: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct MoveContextParams { + /// Context ID to move + pub id: String, + /// New condition map for the context + pub context: serde_json::Value, + /// Reason for this change + pub change_reason: String, + /// Optional description + pub description: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetContextFromConditionParams { + /// Condition to match against (JSON object) + pub context: serde_json::Value, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ValidateContextParams { + /// Condition map: dimension names to their criteria values + pub context: serde_json::Value, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct BulkOperationParams { + /// List of operations: each is one of PUT, REPLACE, DELETE, or MOVE + pub operations: serde_json::Value, + /// Optional config tags header + pub config_tags: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct WeightRecomputeParams { + /// Optional config tags header + pub config_tags: Option, +} + +impl SuperpositionMcpServer { + pub async fn create_context_impl( + &self, + args: CreateContextParams, + ) -> Result { + let ctx_map = json_to_doc_map(args.context).map_err(mcp_err)?; + let ovr_map = json_to_doc_map(args.r#override).map_err(mcp_err)?; + let mut put_builder = superposition_sdk::types::ContextPut::builder() + .set_context(Some(ctx_map)) + .set_override(Some(ovr_map)) + .change_reason(args.change_reason); + if let Some(d) = args.description { + put_builder = put_builder.description(d); + } + let put = put_builder.build().map_err(mcp_err)?; + let mut req = self + .client + .create_context() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .request(put); + if let Some(tags) = args.config_tags { + req = req.config_tags(tags); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = + serde_json::to_string_pretty(&context_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn get_context_impl( + &self, + args: GetContextParams, + ) -> Result { + let resp = self + .client + .get_context() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .id(args.id) + .send() + .await + .map_err(mcp_err)?; + let json = + serde_json::to_string_pretty(&context_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn list_contexts_impl( + &self, + args: ListContextsParams, + ) -> Result { + let mut req = self + .client + .list_contexts() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + if let Some(c) = args.count { + req = req.count(c); + } + if let Some(p) = args.page { + req = req.page(p); + } + if let Some(a) = args.all { + req = req.all(a); + } + if let Some(prefix) = args.prefix { + for p in prefix { + req = req.prefix(p); + } + } + if let Some(sort_on) = args.sort_on { + let sort = match sort_on.as_str() { + "created_at" => superposition_sdk::types::ContextFilterSortOn::CreatedAt, + "weight" => superposition_sdk::types::ContextFilterSortOn::Weight, + _ => superposition_sdk::types::ContextFilterSortOn::LastModifiedAt, + }; + req = req.sort_on(sort); + } + if let Some(sort_by) = args.sort_by { + let sb = match sort_by.as_str() { + "asc" => superposition_sdk::types::SortBy::Asc, + _ => superposition_sdk::types::SortBy::Desc, + }; + req = req.sort_by(sb); + } + if let Some(pt) = args.plaintext { + req = req.plaintext(pt); + } + let resp = req.send().await.map_err(mcp_err)?; + let items: Vec = + resp.data.iter().map(|r| context_to_json!(r)).collect(); + let result = serde_json::json!({ + "total_pages": resp.total_pages, + "total_items": resp.total_items, + "data": items, + }); + let json = serde_json::to_string_pretty(&result).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn delete_context_impl( + &self, + args: DeleteContextParams, + ) -> Result { + let mut req = self + .client + .delete_context() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .id(args.id); + if let Some(tags) = args.config_tags { + req = req.config_tags(tags); + } + req.send().await.map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text( + "Context deleted successfully", + )])) + } + + pub async fn update_override_impl( + &self, + args: UpdateOverrideParams, + ) -> Result { + let ovr_map = json_to_doc_map(args.r#override).map_err(mcp_err)?; + let ctx_ident = if let Some(id) = args.context.get("id").and_then(|v| v.as_str()) + { + superposition_sdk::types::ContextIdentifier::Id(id.to_string()) + } else if let Some(cond) = args.context.get("context") { + let cond_map = json_to_doc_map(cond.clone()).map_err(mcp_err)?; + superposition_sdk::types::ContextIdentifier::Context(cond_map) + } else { + return Err(mcp_err("context must have either 'id' or 'context' field")); + }; + let mut ucr_builder = + superposition_sdk::types::UpdateContextOverrideRequest::builder() + .context(ctx_ident) + .set_override(Some(ovr_map)) + .change_reason(args.change_reason); + if let Some(d) = args.description { + ucr_builder = ucr_builder.description(d); + } + let ucr = ucr_builder.build().map_err(mcp_err)?; + let mut req = self + .client + .update_override() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .request(ucr); + if let Some(tags) = args.config_tags { + req = req.config_tags(tags); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = + serde_json::to_string_pretty(&context_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn move_context_impl( + &self, + args: MoveContextParams, + ) -> Result { + let ctx_map = json_to_doc_map(args.context).map_err(mcp_err)?; + let mut move_builder = superposition_sdk::types::ContextMove::builder() + .set_context(Some(ctx_map)) + .change_reason(args.change_reason); + if let Some(d) = args.description { + move_builder = move_builder.description(d); + } + let mv = move_builder.build().map_err(mcp_err)?; + let resp = self + .client + .move_context() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .id(args.id) + .request(mv) + .send() + .await + .map_err(mcp_err)?; + let json = + serde_json::to_string_pretty(&context_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn get_context_from_condition_impl( + &self, + args: GetContextFromConditionParams, + ) -> Result { + let doc = json_to_doc(args.context); + let resp = self + .client + .get_context_from_condition() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .context(doc) + .send() + .await + .map_err(mcp_err)?; + let json = + serde_json::to_string_pretty(&context_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn weight_recompute_impl( + &self, + args: WeightRecomputeParams, + ) -> Result { + let mut req = self + .client + .weight_recompute() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + if let Some(tags) = args.config_tags { + req = req.config_tags(tags); + } + let resp = req.send().await.map_err(mcp_err)?; + let items: Vec = resp + .data + .as_deref() + .unwrap_or_default() + .iter() + .map(|r| { + serde_json::json!({ + "id": r.id, + "condition": doc_map_to_json(&r.condition), + "old_weight": r.old_weight, + "new_weight": r.new_weight, + }) + }) + .collect(); + let json = serde_json::to_string_pretty(&serde_json::json!({"data": items})) + .map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn validate_context_impl( + &self, + args: ValidateContextParams, + ) -> Result { + let ctx_map = json_to_doc_map(args.context).map_err(mcp_err)?; + self.client + .validate_context() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .set_context(Some(ctx_map)) + .send() + .await + .map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text( + "Context is valid", + )])) + } + + pub async fn bulk_operation_impl( + &self, + args: BulkOperationParams, + ) -> Result { + let json_str = serde_json::to_string(&args.operations).map_err(mcp_err)?; + let result = serde_json::json!({ + "message": "Bulk operations require complex typed input. Please use the Superposition UI or SDK directly.", + "operations_received": args.operations, + }); + let _ = json_str; + let json = serde_json::to_string_pretty(&result).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } +} diff --git a/crates/superposition_mcp/src/tools/default_config.rs b/crates/superposition_mcp/src/tools/default_config.rs new file mode 100644 index 000000000..b5da51c77 --- /dev/null +++ b/crates/superposition_mcp/src/tools/default_config.rs @@ -0,0 +1,170 @@ +use crate::SuperpositionMcpServer; +use crate::helpers::*; +use rmcp::model::*; +use schemars::JsonSchema; +use serde::Deserialize; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateDefaultConfigParams { + pub key: String, + pub value: serde_json::Value, + pub schema: serde_json::Value, + pub description: String, + pub change_reason: String, + pub value_validation_function_name: Option, + pub value_compute_function_name: Option, +} +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetDefaultConfigParams { + pub key: String, +} +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListDefaultConfigsParams { + pub count: Option, + pub page: Option, + pub all: Option, + pub name: Option, +} +#[derive(Debug, Deserialize, JsonSchema)] +pub struct UpdateDefaultConfigParams { + pub key: String, + pub change_reason: String, + pub value: Option, + pub schema: Option, + pub description: Option, + pub value_validation_function_name: Option, + pub value_compute_function_name: Option, +} +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DeleteDefaultConfigParams { + pub key: String, +} + +impl SuperpositionMcpServer { + pub async fn create_default_config_impl( + &self, + args: CreateDefaultConfigParams, + ) -> Result { + let doc_val = json_to_doc(args.value); + let schema_map = json_to_doc_map(args.schema).map_err(mcp_err)?; + let mut req = self + .client + .create_default_config() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .key(args.key) + .value(doc_val) + .set_schema(Some(schema_map)) + .description(args.description) + .change_reason(args.change_reason); + if let Some(f) = args.value_validation_function_name { + req = req.value_validation_function_name(f); + } + if let Some(f) = args.value_compute_function_name { + req = req.value_compute_function_name(f); + } + let resp = req.send().await.map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&default_config_to_json!(resp)) + .map_err(mcp_err)?, + )])) + } + pub async fn get_default_config_impl( + &self, + args: GetDefaultConfigParams, + ) -> Result { + let resp = self + .client + .get_default_config() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .key(args.key) + .send() + .await + .map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&default_config_to_json!(resp)) + .map_err(mcp_err)?, + )])) + } + pub async fn list_default_configs_impl( + &self, + args: ListDefaultConfigsParams, + ) -> Result { + let mut req = self + .client + .list_default_configs() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + if let Some(c) = args.count { + req = req.count(c); + } + if let Some(p) = args.page { + req = req.page(p); + } + if let Some(a) = args.all { + req = req.all(a); + } + if let Some(n) = args.name { + req = req.name(n); + } + let resp = req.send().await.map_err(mcp_err)?; + let items: Vec = resp + .data + .iter() + .map(|r| default_config_to_json!(r)) + .collect(); + let result = serde_json::json!({"total_pages": resp.total_pages, "total_items": resp.total_items, "data": items}); + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&result).map_err(mcp_err)?, + )])) + } + pub async fn update_default_config_impl( + &self, + args: UpdateDefaultConfigParams, + ) -> Result { + let mut req = self + .client + .update_default_config() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .key(args.key) + .change_reason(args.change_reason); + if let Some(v) = args.value { + req = req.value(json_to_doc(v)); + } + if let Some(s) = args.schema { + req = req.set_schema(Some(json_to_doc_map(s).map_err(mcp_err)?)); + } + if let Some(d) = args.description { + req = req.description(d); + } + if let Some(f) = args.value_validation_function_name { + req = req.value_validation_function_name(f); + } + if let Some(f) = args.value_compute_function_name { + req = req.value_compute_function_name(f); + } + let resp = req.send().await.map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&default_config_to_json!(resp)) + .map_err(mcp_err)?, + )])) + } + pub async fn delete_default_config_impl( + &self, + args: DeleteDefaultConfigParams, + ) -> Result { + self.client + .delete_default_config() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .key(args.key) + .send() + .await + .map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text( + "Default config deleted successfully", + )])) + } +} diff --git a/crates/superposition_mcp/src/tools/dimension.rs b/crates/superposition_mcp/src/tools/dimension.rs new file mode 100644 index 000000000..e8f5ad610 --- /dev/null +++ b/crates/superposition_mcp/src/tools/dimension.rs @@ -0,0 +1,168 @@ +use rmcp::model::*; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::SuperpositionMcpServer; +use crate::helpers::*; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateDimensionParams { + pub dimension: String, + pub position: i32, + pub schema: serde_json::Value, + pub description: String, + pub change_reason: String, + pub value_validation_function_name: Option, + pub value_compute_function_name: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetDimensionParams { + pub dimension: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListDimensionsParams { + pub count: Option, + pub page: Option, + pub all: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct UpdateDimensionParams { + pub dimension: String, + pub change_reason: String, + pub schema: Option, + pub position: Option, + pub description: Option, + pub value_validation_function_name: Option, + pub value_compute_function_name: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DeleteDimensionParams { + pub dimension: String, +} + +impl SuperpositionMcpServer { + pub async fn create_dimension_impl( + &self, + args: CreateDimensionParams, + ) -> Result { + let schema_map = json_to_doc_map(args.schema).map_err(mcp_err)?; + let mut req = self + .client + .create_dimension() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .dimension(args.dimension) + .position(args.position) + .set_schema(Some(schema_map)) + .description(args.description) + .change_reason(args.change_reason); + if let Some(f) = args.value_validation_function_name { + req = req.value_validation_function_name(f); + } + if let Some(f) = args.value_compute_function_name { + req = req.value_compute_function_name(f); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = + serde_json::to_string_pretty(&dimension_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn get_dimension_impl( + &self, + args: GetDimensionParams, + ) -> Result { + let resp = self + .client + .get_dimension() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .dimension(args.dimension) + .send() + .await + .map_err(mcp_err)?; + let json = + serde_json::to_string_pretty(&dimension_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn list_dimensions_impl( + &self, + args: ListDimensionsParams, + ) -> Result { + let mut req = self + .client + .list_dimensions() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + if let Some(c) = args.count { + req = req.count(c); + } + if let Some(p) = args.page { + req = req.page(p); + } + if let Some(a) = args.all { + req = req.all(a); + } + let resp = req.send().await.map_err(mcp_err)?; + let items: Vec = + resp.data.iter().map(|r| dimension_to_json!(r)).collect(); + let result = serde_json::json!({"total_pages": resp.total_pages, "total_items": resp.total_items, "data": items}); + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&result).map_err(mcp_err)?, + )])) + } + + pub async fn update_dimension_impl( + &self, + args: UpdateDimensionParams, + ) -> Result { + let mut req = self + .client + .update_dimension() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .dimension(args.dimension) + .change_reason(args.change_reason); + if let Some(s) = args.schema { + req = req.set_schema(Some(json_to_doc_map(s).map_err(mcp_err)?)); + } + if let Some(p) = args.position { + req = req.position(p); + } + if let Some(d) = args.description { + req = req.description(d); + } + if let Some(f) = args.value_validation_function_name { + req = req.value_validation_function_name(f); + } + if let Some(f) = args.value_compute_function_name { + req = req.value_compute_function_name(f); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = + serde_json::to_string_pretty(&dimension_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn delete_dimension_impl( + &self, + args: DeleteDimensionParams, + ) -> Result { + self.client + .delete_dimension() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .dimension(args.dimension) + .send() + .await + .map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text( + "Dimension deleted successfully", + )])) + } +} diff --git a/crates/superposition_mcp/src/tools/experiment.rs b/crates/superposition_mcp/src/tools/experiment.rs new file mode 100644 index 000000000..bd1d76ead --- /dev/null +++ b/crates/superposition_mcp/src/tools/experiment.rs @@ -0,0 +1,355 @@ +use crate::SuperpositionMcpServer; +use crate::helpers::*; +use rmcp::model::*; +use schemars::JsonSchema; +use serde::Deserialize; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateExperimentParams { + pub name: String, + pub context: serde_json::Value, + pub variants: Vec, + pub description: String, + pub change_reason: String, + pub experiment_type: Option, + pub metrics: Option, + pub experiment_group_id: Option, +} +#[derive(Debug, Deserialize, JsonSchema)] +pub struct VariantParam { + pub id: String, + pub variant_type: String, + pub overrides: serde_json::Value, +} +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetExperimentParams { + pub id: String, +} +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListExperimentsParams { + pub count: Option, + pub page: Option, + pub all: Option, + pub status: Option>, + pub experiment_name: Option, + pub sort_on: Option, + pub sort_by: Option, +} +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ConcludeExperimentParams { + pub id: String, + pub chosen_variant: String, + pub change_reason: String, + pub description: Option, +} +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DiscardExperimentParams { + pub id: String, + pub change_reason: String, +} +#[derive(Debug, Deserialize, JsonSchema)] +pub struct RampExperimentParams { + pub id: String, + pub traffic_percentage: i32, + pub change_reason: String, +} +#[derive(Debug, Deserialize, JsonSchema)] +pub struct PauseResumeExperimentParams { + pub id: String, + pub change_reason: String, +} +#[derive(Debug, Deserialize, JsonSchema)] +pub struct UpdateExperimentOverridesParams { + pub id: String, + pub variants: Vec, + pub change_reason: String, + pub description: Option, + pub metrics: Option, + pub experiment_group_id: Option, +} +#[derive(Debug, Deserialize, JsonSchema)] +pub struct VariantUpdateParam { + pub id: String, + pub overrides: serde_json::Value, +} +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ApplicableVariantsParams { + pub context: serde_json::Value, + pub identifier: String, +} + +impl SuperpositionMcpServer { + pub async fn create_experiment_impl( + &self, + args: CreateExperimentParams, + ) -> Result { + let ctx_map = json_to_doc_map(args.context).map_err(mcp_err)?; + let mut req = self + .client + .create_experiment() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .name(args.name) + .set_context(Some(ctx_map)) + .description(args.description) + .change_reason(args.change_reason); + for v in args.variants { + let ovr = json_to_doc_map(v.overrides).map_err(mcp_err)?; + let vt = if v.variant_type.to_uppercase() == "CONTROL" { + superposition_sdk::types::VariantType::Control + } else { + superposition_sdk::types::VariantType::Experimental + }; + req = req.variants( + superposition_sdk::types::Variant::builder() + .id(v.id) + .variant_type(vt) + .set_overrides(Some(ovr)) + .build() + .map_err(mcp_err)?, + ); + } + if let Some(et) = args.experiment_type { + req = req.experiment_type(if et.to_uppercase() == "DELETE_OVERRIDES" { + superposition_sdk::types::ExperimentType::DeleteOverrides + } else { + superposition_sdk::types::ExperimentType::Default + }); + } + if let Some(m) = args.metrics { + req = req.metrics(json_to_doc(m)); + } + if let Some(gid) = args.experiment_group_id { + req = req.experiment_group_id(gid); + } + let resp = req.send().await.map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&experiment_to_json!(resp)).map_err(mcp_err)?, + )])) + } + pub async fn get_experiment_impl( + &self, + args: GetExperimentParams, + ) -> Result { + let resp = self + .client + .get_experiment() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .id(args.id) + .send() + .await + .map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&experiment_to_json!(resp)).map_err(mcp_err)?, + )])) + } + pub async fn list_experiments_impl( + &self, + args: ListExperimentsParams, + ) -> Result { + let mut req = self + .client + .list_experiment() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + if let Some(c) = args.count { + req = req.count(c); + } + if let Some(p) = args.page { + req = req.page(p); + } + if let Some(a) = args.all { + req = req.all(a); + } + if let Some(statuses) = args.status { + for s in statuses { + let st = match s.to_uppercase().as_str() { + "CREATED" => superposition_sdk::types::ExperimentStatusType::Created, + "INPROGRESS" => { + superposition_sdk::types::ExperimentStatusType::Inprogress + } + "CONCLUDED" => { + superposition_sdk::types::ExperimentStatusType::Concluded + } + "DISCARDED" => { + superposition_sdk::types::ExperimentStatusType::Discarded + } + "PAUSED" => superposition_sdk::types::ExperimentStatusType::Paused, + _ => continue, + }; + req = req.status(st); + } + } + if let Some(name) = args.experiment_name { + req = req.experiment_name(name); + } + if let Some(so) = args.sort_on { + req = req.sort_on(if so == "created_at" { + superposition_sdk::types::ExperimentSortOn::CreatedAt + } else { + superposition_sdk::types::ExperimentSortOn::LastModifiedAt + }); + } + if let Some(sb) = args.sort_by { + req = req.sort_by(if sb == "asc" { + superposition_sdk::types::SortBy::Asc + } else { + superposition_sdk::types::SortBy::Desc + }); + } + let resp = req.send().await.map_err(mcp_err)?; + let items: Vec = + resp.data.iter().map(|r| experiment_to_json!(r)).collect(); + Ok(CallToolResult::success(vec![Content::text(serde_json::to_string_pretty(&serde_json::json!({"total_pages": resp.total_pages, "total_items": resp.total_items, "data": items})).map_err(mcp_err)?)])) + } + pub async fn conclude_experiment_impl( + &self, + args: ConcludeExperimentParams, + ) -> Result { + let mut req = self + .client + .conclude_experiment() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .id(args.id) + .chosen_variant(args.chosen_variant) + .change_reason(args.change_reason); + if let Some(d) = args.description { + req = req.description(d); + } + let resp = req.send().await.map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&experiment_to_json!(resp)).map_err(mcp_err)?, + )])) + } + pub async fn discard_experiment_impl( + &self, + args: DiscardExperimentParams, + ) -> Result { + let resp = self + .client + .discard_experiment() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .id(args.id) + .change_reason(args.change_reason) + .send() + .await + .map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&experiment_to_json!(resp)).map_err(mcp_err)?, + )])) + } + pub async fn ramp_experiment_impl( + &self, + args: RampExperimentParams, + ) -> Result { + let resp = self + .client + .ramp_experiment() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .id(args.id) + .traffic_percentage(args.traffic_percentage) + .change_reason(args.change_reason) + .send() + .await + .map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&experiment_to_json!(resp)).map_err(mcp_err)?, + )])) + } + pub async fn pause_experiment_impl( + &self, + args: PauseResumeExperimentParams, + ) -> Result { + let resp = self + .client + .pause_experiment() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .id(args.id) + .change_reason(args.change_reason) + .send() + .await + .map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&experiment_to_json!(resp)).map_err(mcp_err)?, + )])) + } + pub async fn resume_experiment_impl( + &self, + args: PauseResumeExperimentParams, + ) -> Result { + let resp = self + .client + .resume_experiment() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .id(args.id) + .change_reason(args.change_reason) + .send() + .await + .map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&experiment_to_json!(resp)).map_err(mcp_err)?, + )])) + } + pub async fn update_experiment_overrides_impl( + &self, + args: UpdateExperimentOverridesParams, + ) -> Result { + let mut req = self + .client + .update_overrides_experiment() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .id(args.id) + .change_reason(args.change_reason); + for v in args.variants { + let ovr = json_to_doc_map(v.overrides).map_err(mcp_err)?; + req = req.variant_list( + superposition_sdk::types::VariantUpdateRequest::builder() + .id(v.id) + .set_overrides(Some(ovr)) + .build() + .map_err(mcp_err)?, + ); + } + if let Some(d) = args.description { + req = req.description(d); + } + if let Some(m) = args.metrics { + req = req.metrics(json_to_doc(m)); + } + if let Some(gid) = args.experiment_group_id { + req = req.experiment_group_id(gid); + } + let resp = req.send().await.map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&experiment_to_json!(resp)).map_err(mcp_err)?, + )])) + } + pub async fn applicable_variants_impl( + &self, + args: ApplicableVariantsParams, + ) -> Result { + let ctx_map = json_to_doc_map(args.context).map_err(mcp_err)?; + let resp = self + .client + .applicable_variants() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .set_context(Some(ctx_map)) + .identifier(args.identifier) + .send() + .await + .map_err(mcp_err)?; + let items: Vec = resp.data.iter().map(|v| serde_json::json!({"id": v.id, "variant_type": format!("{:?}", v.variant_type), "overrides": doc_map_to_json(&v.overrides), "context_id": v.context_id, "override_id": v.override_id})).collect(); + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&serde_json::json!({"data": items})) + .map_err(mcp_err)?, + )])) + } +} diff --git a/crates/superposition_mcp/src/tools/experiment_group.rs b/crates/superposition_mcp/src/tools/experiment_group.rs new file mode 100644 index 000000000..d2f0d1039 --- /dev/null +++ b/crates/superposition_mcp/src/tools/experiment_group.rs @@ -0,0 +1,218 @@ +use crate::SuperpositionMcpServer; +use crate::helpers::*; +use rmcp::model::*; +use schemars::JsonSchema; +use serde::Deserialize; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateExperimentGroupParams { + pub name: String, + pub description: String, + pub change_reason: String, + pub context: serde_json::Value, + pub traffic_percentage: i32, + pub member_experiment_ids: Option>, +} +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetExperimentGroupParams { + pub id: String, +} +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListExperimentGroupsParams { + pub count: Option, + pub page: Option, + pub all: Option, + pub name: Option, + pub sort_on: Option, + pub sort_by: Option, +} +#[derive(Debug, Deserialize, JsonSchema)] +pub struct UpdateExperimentGroupParams { + pub id: String, + pub change_reason: String, + pub description: Option, + pub traffic_percentage: Option, +} +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DeleteExperimentGroupParams { + pub id: String, +} +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ModifyGroupMembersParams { + pub id: String, + pub change_reason: String, + pub member_experiment_ids: Vec, +} + +impl SuperpositionMcpServer { + pub async fn create_experiment_group_impl( + &self, + args: CreateExperimentGroupParams, + ) -> Result { + let ctx_map = json_to_doc_map(args.context).map_err(mcp_err)?; + let mut req = self + .client + .create_experiment_group() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .name(args.name) + .description(args.description) + .change_reason(args.change_reason) + .set_context(Some(ctx_map)) + .traffic_percentage(args.traffic_percentage); + if let Some(m) = args.member_experiment_ids { + for id in m { + req = req.member_experiment_ids(id); + } + } + let resp = req.send().await.map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&experiment_group_to_json!(resp)) + .map_err(mcp_err)?, + )])) + } + pub async fn get_experiment_group_impl( + &self, + args: GetExperimentGroupParams, + ) -> Result { + let resp = self + .client + .get_experiment_group() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .id(args.id) + .send() + .await + .map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&experiment_group_to_json!(resp)) + .map_err(mcp_err)?, + )])) + } + pub async fn list_experiment_groups_impl( + &self, + args: ListExperimentGroupsParams, + ) -> Result { + let mut req = self + .client + .list_experiment_groups() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + if let Some(c) = args.count { + req = req.count(c); + } + if let Some(p) = args.page { + req = req.page(p); + } + if let Some(a) = args.all { + req = req.all(a); + } + if let Some(n) = args.name { + req = req.name(n); + } + if let Some(so) = args.sort_on { + req = req.sort_on(match so.as_str() { + "name" => superposition_sdk::types::ExperimentGroupSortOn::Name, + "created_at" => { + superposition_sdk::types::ExperimentGroupSortOn::CreatedAt + } + _ => superposition_sdk::types::ExperimentGroupSortOn::LastModifiedAt, + }); + } + if let Some(sb) = args.sort_by { + req = req.sort_by(if sb == "asc" { + superposition_sdk::types::SortBy::Asc + } else { + superposition_sdk::types::SortBy::Desc + }); + } + let resp = req.send().await.map_err(mcp_err)?; + let items: Vec = resp + .data + .iter() + .map(|r| experiment_group_to_json!(r)) + .collect(); + Ok(CallToolResult::success(vec![Content::text(serde_json::to_string_pretty(&serde_json::json!({"total_pages": resp.total_pages, "total_items": resp.total_items, "data": items})).map_err(mcp_err)?)])) + } + pub async fn update_experiment_group_impl( + &self, + args: UpdateExperimentGroupParams, + ) -> Result { + let mut req = self + .client + .update_experiment_group() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .id(args.id) + .change_reason(args.change_reason); + if let Some(d) = args.description { + req = req.description(d); + } + if let Some(tp) = args.traffic_percentage { + req = req.traffic_percentage(tp); + } + let resp = req.send().await.map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&experiment_group_to_json!(resp)) + .map_err(mcp_err)?, + )])) + } + pub async fn delete_experiment_group_impl( + &self, + args: DeleteExperimentGroupParams, + ) -> Result { + let resp = self + .client + .delete_experiment_group() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .id(args.id) + .send() + .await + .map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&experiment_group_to_json!(resp)) + .map_err(mcp_err)?, + )])) + } + pub async fn add_members_to_group_impl( + &self, + args: ModifyGroupMembersParams, + ) -> Result { + let mut req = self + .client + .add_members_to_group() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .id(args.id) + .change_reason(args.change_reason); + for m in args.member_experiment_ids { + req = req.member_experiment_ids(m); + } + let resp = req.send().await.map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&experiment_group_to_json!(resp)) + .map_err(mcp_err)?, + )])) + } + pub async fn remove_members_from_group_impl( + &self, + args: ModifyGroupMembersParams, + ) -> Result { + let mut req = self + .client + .remove_members_from_group() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .id(args.id) + .change_reason(args.change_reason); + for m in args.member_experiment_ids { + req = req.member_experiment_ids(m); + } + let resp = req.send().await.map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&experiment_group_to_json!(resp)) + .map_err(mcp_err)?, + )])) + } +} diff --git a/crates/superposition_mcp/src/tools/function.rs b/crates/superposition_mcp/src/tools/function.rs new file mode 100644 index 000000000..e8d11cd7d --- /dev/null +++ b/crates/superposition_mcp/src/tools/function.rs @@ -0,0 +1,201 @@ +use crate::SuperpositionMcpServer; +use crate::helpers::*; +use rmcp::model::*; +use schemars::JsonSchema; +use serde::Deserialize; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateFunctionParams { + pub function_name: String, + pub description: String, + pub change_reason: String, + pub function: String, + pub runtime_version: String, + pub function_type: String, +} +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetFunctionParams { + pub function_name: String, +} +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListFunctionsParams { + pub count: Option, + pub page: Option, + pub all: Option, + pub function_type: Option>, +} +#[derive(Debug, Deserialize, JsonSchema)] +pub struct UpdateFunctionParams { + pub function_name: String, + pub change_reason: String, + pub description: Option, + pub function: Option, + pub runtime_version: Option, +} +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DeleteFunctionParams { + pub function_name: String, +} +#[derive(Debug, Deserialize, JsonSchema)] +pub struct PublishFunctionParams { + pub function_name: String, + pub change_reason: String, +} +#[derive(Debug, Deserialize, JsonSchema)] +pub struct TestFunctionParams { + pub function_name: String, + pub stage: String, + pub request: serde_json::Value, +} + +fn parse_ft(s: &str) -> superposition_sdk::types::FunctionTypes { + match s.to_uppercase().as_str() { + "VALUE_COMPUTE" => superposition_sdk::types::FunctionTypes::ValueCompute, + "CONTEXT_VALIDATION" => { + superposition_sdk::types::FunctionTypes::ContextValidation + } + "CHANGE_REASON_VALIDATION" => { + superposition_sdk::types::FunctionTypes::ChangeReasonValidation + } + _ => superposition_sdk::types::FunctionTypes::ValueValidation, + } +} + +impl SuperpositionMcpServer { + pub async fn create_function_impl( + &self, + args: CreateFunctionParams, + ) -> Result { + let resp = self + .client + .create_function() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .function_name(args.function_name) + .description(args.description) + .change_reason(args.change_reason) + .function(args.function) + .runtime_version(superposition_sdk::types::FunctionRuntimeVersion::V1) + .function_type(parse_ft(&args.function_type)) + .send() + .await + .map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&function_to_json!(resp)).map_err(mcp_err)?, + )])) + } + pub async fn get_function_impl( + &self, + args: GetFunctionParams, + ) -> Result { + let resp = self + .client + .get_function() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .function_name(args.function_name) + .send() + .await + .map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&function_to_json!(resp)).map_err(mcp_err)?, + )])) + } + pub async fn list_functions_impl( + &self, + args: ListFunctionsParams, + ) -> Result { + let mut req = self + .client + .list_function() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + if let Some(c) = args.count { + req = req.count(c); + } + if let Some(p) = args.page { + req = req.page(p); + } + if let Some(a) = args.all { + req = req.all(a); + } + if let Some(types) = args.function_type { + for t in types { + req = req.function_type(parse_ft(&t)); + } + } + let resp = req.send().await.map_err(mcp_err)?; + let items: Vec = + resp.data.iter().map(|r| function_to_json!(r)).collect(); + Ok(CallToolResult::success(vec![Content::text(serde_json::to_string_pretty(&serde_json::json!({"total_pages": resp.total_pages, "total_items": resp.total_items, "data": items})).map_err(mcp_err)?)])) + } + pub async fn update_function_impl( + &self, + args: UpdateFunctionParams, + ) -> Result { + let mut req = self + .client + .update_function() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .function_name(args.function_name) + .change_reason(args.change_reason); + if let Some(d) = args.description { + req = req.description(d); + } + if let Some(f) = args.function { + req = req.function(f); + } + if args.runtime_version.is_some() { + req = + req.runtime_version(superposition_sdk::types::FunctionRuntimeVersion::V1); + } + let resp = req.send().await.map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&function_to_json!(resp)).map_err(mcp_err)?, + )])) + } + pub async fn delete_function_impl( + &self, + args: DeleteFunctionParams, + ) -> Result { + self.client + .delete_function() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .function_name(args.function_name) + .send() + .await + .map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text( + "Function deleted successfully", + )])) + } + pub async fn publish_function_impl( + &self, + args: PublishFunctionParams, + ) -> Result { + let resp = self + .client + .publish() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .function_name(args.function_name) + .change_reason(args.change_reason) + .send() + .await + .map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&function_to_json!(resp)).map_err(mcp_err)?, + )])) + } + pub async fn test_function_impl( + &self, + args: TestFunctionParams, + ) -> Result { + let result = serde_json::json!({"message": "Function testing requires a typed FunctionExecutionRequest. Use the Superposition UI or SDK directly.", "function_name": args.function_name, "stage": args.stage, "request": args.request}); + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&result).map_err(mcp_err)?, + )])) + } +} diff --git a/crates/superposition_mcp/src/tools/organisation.rs b/crates/superposition_mcp/src/tools/organisation.rs new file mode 100644 index 000000000..9f24ffc5a --- /dev/null +++ b/crates/superposition_mcp/src/tools/organisation.rs @@ -0,0 +1,161 @@ +use rmcp::model::*; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::SuperpositionMcpServer; +use crate::helpers::*; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateOrganisationParams { + /// Organisation name + pub name: String, + /// Admin email address + pub admin_email: String, + /// Optional country code + pub country_code: Option, + /// Optional contact email + pub contact_email: Option, + /// Optional contact phone + pub contact_phone: Option, + /// Optional business sector + pub sector: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetOrganisationParams { + /// Organisation ID + pub id: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListOrganisationsParams { + /// Number of items per page + pub count: Option, + /// Page number (starting from 1) + pub page: Option, + /// If true, returns all items ignoring pagination + pub all: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct UpdateOrganisationParams { + /// Organisation ID + pub id: String, + /// Updated status: Active, Inactive, or PendingKyb + pub status: Option, + /// Updated country code + pub country_code: Option, + /// Updated contact email + pub contact_email: Option, + /// Updated contact phone + pub contact_phone: Option, + /// Updated admin email + pub admin_email: Option, + /// Updated sector + pub sector: Option, +} + +impl SuperpositionMcpServer { + pub async fn create_organisation_impl( + &self, + args: CreateOrganisationParams, + ) -> Result { + let mut req = self + .client + .create_organisation() + .name(args.name) + .admin_email(args.admin_email); + if let Some(cc) = args.country_code { + req = req.country_code(cc); + } + if let Some(ce) = args.contact_email { + req = req.contact_email(ce); + } + if let Some(cp) = args.contact_phone { + req = req.contact_phone(cp); + } + if let Some(s) = args.sector { + req = req.sector(s); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&organisation_to_json!(resp)) + .map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn get_organisation_impl( + &self, + args: GetOrganisationParams, + ) -> Result { + let resp = self + .client + .get_organisation() + .id(args.id) + .send() + .await + .map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&organisation_to_json!(resp)) + .map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn list_organisations_impl( + &self, + args: ListOrganisationsParams, + ) -> Result { + let mut req = self.client.list_organisation(); + if let Some(c) = args.count { + req = req.count(c); + } + if let Some(p) = args.page { + req = req.page(p); + } + if let Some(a) = args.all { + req = req.all(a); + } + let resp = req.send().await.map_err(mcp_err)?; + let items: Vec = + resp.data.iter().map(|r| organisation_to_json!(r)).collect(); + let result = serde_json::json!({ + "total_pages": resp.total_pages, + "total_items": resp.total_items, + "data": items, + }); + let json = serde_json::to_string_pretty(&result).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn update_organisation_impl( + &self, + args: UpdateOrganisationParams, + ) -> Result { + let mut req = self.client.update_organisation().id(args.id); + if let Some(st) = args.status { + let status = match st.as_str() { + "Inactive" => superposition_sdk::types::OrgStatus::Inactive, + "PendingKyb" => superposition_sdk::types::OrgStatus::PendingKyb, + _ => superposition_sdk::types::OrgStatus::Active, + }; + req = req.status(status); + } + if let Some(cc) = args.country_code { + req = req.country_code(cc); + } + if let Some(ce) = args.contact_email { + req = req.contact_email(ce); + } + if let Some(cp) = args.contact_phone { + req = req.contact_phone(cp); + } + if let Some(ae) = args.admin_email { + req = req.admin_email(ae); + } + if let Some(s) = args.sector { + req = req.sector(s); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&organisation_to_json!(resp)) + .map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } +} diff --git a/crates/superposition_mcp/src/tools/secret.rs b/crates/superposition_mcp/src/tools/secret.rs new file mode 100644 index 000000000..903fb5743 --- /dev/null +++ b/crates/superposition_mcp/src/tools/secret.rs @@ -0,0 +1,183 @@ +use rmcp::model::*; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::SuperpositionMcpServer; +use crate::helpers::*; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateSecretParams { + /// Secret name + pub name: String, + /// Plaintext value to encrypt and store + pub value: String, + /// Human-readable description + pub description: String, + /// Reason for this change + pub change_reason: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetSecretParams { + /// Secret name to retrieve + pub name: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListSecretsParams { + /// Number of items per page + pub count: Option, + /// Page number (starting from 1) + pub page: Option, + /// If true, returns all items ignoring pagination + pub all: Option, + /// Sort field: name, created_at, or last_modified_at + pub sort_on: Option, + /// Sort order: asc or desc + pub sort_by: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct UpdateSecretParams { + /// Secret name to update + pub name: String, + /// Reason for this change + pub change_reason: String, + /// New plaintext value to encrypt + pub value: Option, + /// Updated description + pub description: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DeleteSecretParams { + /// Secret name to delete + pub name: String, +} + +impl SuperpositionMcpServer { + pub async fn create_secret_impl( + &self, + args: CreateSecretParams, + ) -> Result { + let resp = self + .client + .create_secret() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .name(args.name) + .value(args.value) + .description(args.description) + .change_reason(args.change_reason) + .send() + .await + .map_err(mcp_err)?; + let json = + serde_json::to_string_pretty(&secret_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn get_secret_impl( + &self, + args: GetSecretParams, + ) -> Result { + let resp = self + .client + .get_secret() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .name(args.name) + .send() + .await + .map_err(mcp_err)?; + let json = + serde_json::to_string_pretty(&secret_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn list_secrets_impl( + &self, + args: ListSecretsParams, + ) -> Result { + let mut req = self + .client + .list_secrets() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + if let Some(c) = args.count { + req = req.count(c); + } + if let Some(p) = args.page { + req = req.page(p); + } + if let Some(a) = args.all { + req = req.all(a); + } + if let Some(sort_on) = args.sort_on { + let so = match sort_on.as_str() { + "name" => superposition_sdk::types::SecretSortOn::Name, + "created_at" => superposition_sdk::types::SecretSortOn::CreatedAt, + _ => superposition_sdk::types::SecretSortOn::LastModifiedAt, + }; + req = req.sort_on(so); + } + if let Some(sort_by) = args.sort_by { + let sb = match sort_by.as_str() { + "asc" => superposition_sdk::types::SortBy::Asc, + _ => superposition_sdk::types::SortBy::Desc, + }; + req = req.sort_by(sb); + } + let resp = req.send().await.map_err(mcp_err)?; + let items: Vec = + resp.data.iter().map(|r| secret_to_json!(r)).collect(); + let result = serde_json::json!({ + "total_pages": resp.total_pages, + "total_items": resp.total_items, + "data": items, + }); + let json = serde_json::to_string_pretty(&result).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn update_secret_impl( + &self, + args: UpdateSecretParams, + ) -> Result { + let mut req = self + .client + .update_secret() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .name(args.name) + .change_reason(args.change_reason); + if let Some(v) = args.value { + req = req.value(v); + } + if let Some(d) = args.description { + req = req.description(d); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = + serde_json::to_string_pretty(&secret_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn delete_secret_impl( + &self, + args: DeleteSecretParams, + ) -> Result { + let resp = self + .client + .delete_secret() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .name(args.name) + .send() + .await + .map_err(mcp_err)?; + let json = + serde_json::to_string_pretty(&secret_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } +} diff --git a/crates/superposition_mcp/src/tools/type_template.rs b/crates/superposition_mcp/src/tools/type_template.rs new file mode 100644 index 000000000..7470c1d99 --- /dev/null +++ b/crates/superposition_mcp/src/tools/type_template.rs @@ -0,0 +1,167 @@ +use rmcp::model::*; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::SuperpositionMcpServer; +use crate::helpers::*; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateTypeTemplateParams { + /// Type template name + pub type_name: String, + /// JSON schema defining the type + pub type_schema: serde_json::Value, + /// Human-readable description + pub description: String, + /// Reason for this change + pub change_reason: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetTypeTemplateParams { + /// Type template name to retrieve + pub type_name: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListTypeTemplatesParams { + /// Number of items per page + pub count: Option, + /// Page number (starting from 1) + pub page: Option, + /// If true, returns all items ignoring pagination + pub all: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct UpdateTypeTemplateParams { + /// Type template name to update + pub type_name: String, + /// Updated JSON schema + pub type_schema: serde_json::Value, + /// Reason for this change + pub change_reason: String, + /// Optional updated description + pub description: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DeleteTypeTemplateParams { + /// Type template name to delete + pub type_name: String, +} + +impl SuperpositionMcpServer { + pub async fn create_type_template_impl( + &self, + args: CreateTypeTemplateParams, + ) -> Result { + let schema_map = json_to_doc_map(args.type_schema).map_err(mcp_err)?; + let resp = self + .client + .create_type_templates() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .type_name(args.type_name) + .set_type_schema(Some(schema_map)) + .description(args.description) + .change_reason(args.change_reason) + .send() + .await + .map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&type_template_to_json!(resp)) + .map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn get_type_template_impl( + &self, + args: GetTypeTemplateParams, + ) -> Result { + let resp = self + .client + .get_type_template() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .type_name(args.type_name) + .send() + .await + .map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&type_template_to_json!(resp)) + .map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn list_type_templates_impl( + &self, + args: ListTypeTemplatesParams, + ) -> Result { + let mut req = self + .client + .get_type_templates_list() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + if let Some(c) = args.count { + req = req.count(c); + } + if let Some(p) = args.page { + req = req.page(p); + } + if let Some(a) = args.all { + req = req.all(a); + } + let resp = req.send().await.map_err(mcp_err)?; + let items: Vec = resp + .data + .iter() + .map(|r| type_template_to_json!(r)) + .collect(); + let result = serde_json::json!({ + "total_pages": resp.total_pages, + "total_items": resp.total_items, + "data": items, + }); + let json = serde_json::to_string_pretty(&result).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn update_type_template_impl( + &self, + args: UpdateTypeTemplateParams, + ) -> Result { + let schema_map = json_to_doc_map(args.type_schema).map_err(mcp_err)?; + let mut req = self + .client + .update_type_templates() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .type_name(args.type_name) + .set_type_schema(Some(schema_map)) + .change_reason(args.change_reason); + if let Some(d) = args.description { + req = req.description(d); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&type_template_to_json!(resp)) + .map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn delete_type_template_impl( + &self, + args: DeleteTypeTemplateParams, + ) -> Result { + let resp = self + .client + .delete_type_templates() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .type_name(args.type_name) + .send() + .await + .map_err(mcp_err)?; + let json = serde_json::to_string_pretty(&type_template_to_json!(resp)) + .map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } +} diff --git a/crates/superposition_mcp/src/tools/variable.rs b/crates/superposition_mcp/src/tools/variable.rs new file mode 100644 index 000000000..23cc635eb --- /dev/null +++ b/crates/superposition_mcp/src/tools/variable.rs @@ -0,0 +1,183 @@ +use rmcp::model::*; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::SuperpositionMcpServer; +use crate::helpers::*; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateVariableParams { + /// Variable name + pub name: String, + /// Variable value + pub value: String, + /// Human-readable description + pub description: String, + /// Reason for this change + pub change_reason: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetVariableParams { + /// Variable name to retrieve + pub name: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListVariablesParams { + /// Number of items per page + pub count: Option, + /// Page number (starting from 1) + pub page: Option, + /// If true, returns all items ignoring pagination + pub all: Option, + /// Sort field: name, created_at, or last_modified_at + pub sort_on: Option, + /// Sort order: asc or desc + pub sort_by: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct UpdateVariableParams { + /// Variable name to update + pub name: String, + /// Reason for this change + pub change_reason: String, + /// Updated value + pub value: Option, + /// Updated description + pub description: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DeleteVariableParams { + /// Variable name to delete + pub name: String, +} + +impl SuperpositionMcpServer { + pub async fn create_variable_impl( + &self, + args: CreateVariableParams, + ) -> Result { + let resp = self + .client + .create_variable() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .name(args.name) + .value(args.value) + .description(args.description) + .change_reason(args.change_reason) + .send() + .await + .map_err(mcp_err)?; + let json = + serde_json::to_string_pretty(&variable_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn get_variable_impl( + &self, + args: GetVariableParams, + ) -> Result { + let resp = self + .client + .get_variable() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .name(args.name) + .send() + .await + .map_err(mcp_err)?; + let json = + serde_json::to_string_pretty(&variable_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn list_variables_impl( + &self, + args: ListVariablesParams, + ) -> Result { + let mut req = self + .client + .list_variables() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + if let Some(c) = args.count { + req = req.count(c); + } + if let Some(p) = args.page { + req = req.page(p); + } + if let Some(a) = args.all { + req = req.all(a); + } + if let Some(sort_on) = args.sort_on { + let so = match sort_on.as_str() { + "name" => superposition_sdk::types::VariableSortOn::Name, + "created_at" => superposition_sdk::types::VariableSortOn::CreatedAt, + _ => superposition_sdk::types::VariableSortOn::LastModifiedAt, + }; + req = req.sort_on(so); + } + if let Some(sort_by) = args.sort_by { + let sb = match sort_by.as_str() { + "asc" => superposition_sdk::types::SortBy::Asc, + _ => superposition_sdk::types::SortBy::Desc, + }; + req = req.sort_by(sb); + } + let resp = req.send().await.map_err(mcp_err)?; + let items: Vec = + resp.data.iter().map(|r| variable_to_json!(r)).collect(); + let result = serde_json::json!({ + "total_pages": resp.total_pages, + "total_items": resp.total_items, + "data": items, + }); + let json = serde_json::to_string_pretty(&result).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn update_variable_impl( + &self, + args: UpdateVariableParams, + ) -> Result { + let mut req = self + .client + .update_variable() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .name(args.name) + .change_reason(args.change_reason); + if let Some(v) = args.value { + req = req.value(v); + } + if let Some(d) = args.description { + req = req.description(d); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = + serde_json::to_string_pretty(&variable_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn delete_variable_impl( + &self, + args: DeleteVariableParams, + ) -> Result { + let resp = self + .client + .delete_variable() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .name(args.name) + .send() + .await + .map_err(mcp_err)?; + let json = + serde_json::to_string_pretty(&variable_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } +} diff --git a/crates/superposition_mcp/src/tools/webhook.rs b/crates/superposition_mcp/src/tools/webhook.rs new file mode 100644 index 000000000..f7740904d --- /dev/null +++ b/crates/superposition_mcp/src/tools/webhook.rs @@ -0,0 +1,237 @@ +use rmcp::model::*; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::SuperpositionMcpServer; +use crate::helpers::*; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateWebhookParams { + /// Webhook name + pub name: String, + /// Human-readable description + pub description: String, + /// Whether the webhook is enabled + pub enabled: bool, + /// Target URL for the webhook + pub url: String, + /// HTTP method: GET, POST, PUT, PATCH, DELETE, HEAD + pub method: String, + /// List of event names that trigger the webhook + pub events: Vec, + /// Reason for this change + pub change_reason: String, + /// Optional custom headers (JSON object) + pub custom_headers: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetWebhookParams { + /// Webhook name to retrieve + pub name: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListWebhooksParams { + /// Number of items per page + pub count: Option, + /// Page number (starting from 1) + pub page: Option, + /// If true, returns all items ignoring pagination + pub all: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct UpdateWebhookParams { + /// Webhook name to update + pub name: String, + /// Reason for this change + pub change_reason: String, + /// Updated description + pub description: Option, + /// Updated enabled status + pub enabled: Option, + /// Updated target URL + pub url: Option, + /// Updated HTTP method + pub method: Option, + /// Updated events list + pub events: Option>, + /// Updated custom headers + pub custom_headers: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DeleteWebhookParams { + /// Webhook name to delete + pub name: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetWebhookByEventParams { + /// Event name to look up + pub event: String, +} + +fn parse_http_method(s: &str) -> superposition_sdk::types::HttpMethod { + match s.to_uppercase().as_str() { + "GET" => superposition_sdk::types::HttpMethod::Get, + "PUT" => superposition_sdk::types::HttpMethod::Put, + "PATCH" => superposition_sdk::types::HttpMethod::Patch, + "DELETE" => superposition_sdk::types::HttpMethod::Delete, + "HEAD" => superposition_sdk::types::HttpMethod::Head, + _ => superposition_sdk::types::HttpMethod::Post, + } +} + +impl SuperpositionMcpServer { + pub async fn create_webhook_impl( + &self, + args: CreateWebhookParams, + ) -> Result { + let method = parse_http_method(&args.method); + let mut req = self + .client + .create_webhook() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .name(args.name) + .description(args.description) + .enabled(args.enabled) + .url(args.url) + .method(method) + .change_reason(args.change_reason); + for e in args.events { + req = req.events(e); + } + if let Some(ch) = args.custom_headers { + let headers_map = json_to_doc_map(ch).map_err(mcp_err)?; + req = req.set_custom_headers(Some(headers_map)); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = + serde_json::to_string_pretty(&webhook_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn get_webhook_impl( + &self, + args: GetWebhookParams, + ) -> Result { + let resp = self + .client + .get_webhook() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .name(args.name) + .send() + .await + .map_err(mcp_err)?; + let json = + serde_json::to_string_pretty(&webhook_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn list_webhooks_impl( + &self, + args: ListWebhooksParams, + ) -> Result { + let mut req = self + .client + .list_webhook() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id); + if let Some(c) = args.count { + req = req.count(c); + } + if let Some(p) = args.page { + req = req.page(p); + } + if let Some(a) = args.all { + req = req.all(a); + } + let resp = req.send().await.map_err(mcp_err)?; + let items: Vec = + resp.data.iter().map(|r| webhook_to_json!(r)).collect(); + let result = serde_json::json!({ + "total_pages": resp.total_pages, + "total_items": resp.total_items, + "data": items, + }); + let json = serde_json::to_string_pretty(&result).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn update_webhook_impl( + &self, + args: UpdateWebhookParams, + ) -> Result { + let mut req = self + .client + .update_webhook() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .name(args.name) + .change_reason(args.change_reason); + if let Some(d) = args.description { + req = req.description(d); + } + if let Some(e) = args.enabled { + req = req.enabled(e); + } + if let Some(u) = args.url { + req = req.url(u); + } + if let Some(m) = args.method { + req = req.method(parse_http_method(&m)); + } + if let Some(events) = args.events { + for e in events { + req = req.events(e); + } + } + if let Some(ch) = args.custom_headers { + let headers_map = json_to_doc_map(ch).map_err(mcp_err)?; + req = req.set_custom_headers(Some(headers_map)); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = + serde_json::to_string_pretty(&webhook_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn delete_webhook_impl( + &self, + args: DeleteWebhookParams, + ) -> Result { + self.client + .delete_webhook() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .name(args.name) + .send() + .await + .map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text( + "Webhook deleted successfully", + )])) + } + + pub async fn get_webhook_by_event_impl( + &self, + args: GetWebhookByEventParams, + ) -> Result { + let resp = self + .client + .get_webhook_by_event() + .workspace_id(&self.config.workspace_id) + .org_id(&self.config.org_id) + .event(args.event) + .send() + .await + .map_err(mcp_err)?; + let json = + serde_json::to_string_pretty(&webhook_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } +} diff --git a/crates/superposition_mcp/src/tools/workspace.rs b/crates/superposition_mcp/src/tools/workspace.rs new file mode 100644 index 000000000..86b1af06c --- /dev/null +++ b/crates/superposition_mcp/src/tools/workspace.rs @@ -0,0 +1,147 @@ +use rmcp::model::*; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::SuperpositionMcpServer; +use crate::helpers::*; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateWorkspaceParams { + /// Workspace name + pub workspace_name: String, + /// Workspace admin email + pub workspace_admin_email: String, + /// Optional workspace status: ENABLED or DISABLED + pub workspace_status: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetWorkspaceParams { + /// Workspace name to retrieve + pub workspace_name: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListWorkspacesParams { + /// Number of items per page + pub count: Option, + /// Page number (starting from 1) + pub page: Option, + /// If true, returns all items ignoring pagination + pub all: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct UpdateWorkspaceParams { + /// Workspace name to update + pub workspace_name: String, + /// Updated admin email + pub workspace_admin_email: Option, + /// Updated status: ENABLED or DISABLED + pub workspace_status: Option, + /// Updated config version (pass "null" to unset) + pub config_version: Option, + /// Updated mandatory dimensions list + pub mandatory_dimensions: Option>, +} + +impl SuperpositionMcpServer { + pub async fn create_workspace_impl( + &self, + args: CreateWorkspaceParams, + ) -> Result { + let mut req = self + .client + .create_workspace() + .org_id(&self.config.org_id) + .workspace_name(args.workspace_name) + .workspace_admin_email(args.workspace_admin_email); + if let Some(ws) = args.workspace_status { + let status = match ws.to_uppercase().as_str() { + "DISABLED" => superposition_sdk::types::WorkspaceStatus::Disabled, + _ => superposition_sdk::types::WorkspaceStatus::Enabled, + }; + req = req.workspace_status(status); + } + let resp = req.send().await.map_err(mcp_err)?; + let json = + serde_json::to_string_pretty(&workspace_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn get_workspace_impl( + &self, + args: GetWorkspaceParams, + ) -> Result { + let resp = self + .client + .get_workspace() + .org_id(&self.config.org_id) + .workspace_name(args.workspace_name) + .send() + .await + .map_err(mcp_err)?; + let json = + serde_json::to_string_pretty(&workspace_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn list_workspaces_impl( + &self, + args: ListWorkspacesParams, + ) -> Result { + let mut req = self.client.list_workspace().org_id(&self.config.org_id); + if let Some(c) = args.count { + req = req.count(c); + } + if let Some(p) = args.page { + req = req.page(p); + } + if let Some(a) = args.all { + req = req.all(a); + } + let resp = req.send().await.map_err(mcp_err)?; + let items: Vec = + resp.data.iter().map(|r| workspace_to_json!(r)).collect(); + let result = serde_json::json!({ + "total_pages": resp.total_pages, + "total_items": resp.total_items, + "data": items, + }); + let json = serde_json::to_string_pretty(&result).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + pub async fn update_workspace_impl( + &self, + args: UpdateWorkspaceParams, + ) -> Result { + let mut req = self + .client + .update_workspace() + .org_id(&self.config.org_id) + .workspace_name(args.workspace_name); + if let Some(ae) = args.workspace_admin_email { + req = req.workspace_admin_email(ae); + } + if let Some(ws) = args.workspace_status { + let status = match ws.to_uppercase().as_str() { + "DISABLED" => superposition_sdk::types::WorkspaceStatus::Disabled, + _ => superposition_sdk::types::WorkspaceStatus::Enabled, + }; + req = req.workspace_status(status); + } + if let Some(cv) = args.config_version { + req = req.config_version(cv); + } + if let Some(md) = args.mandatory_dimensions { + for d in md { + req = req.mandatory_dimensions(d); + } + } + let resp = req.send().await.map_err(mcp_err)?; + let json = + serde_json::to_string_pretty(&workspace_to_json!(resp)).map_err(mcp_err)?; + Ok(CallToolResult::success(vec![Content::text(json)])) + } +} diff --git a/crates/superposition_mcp/tests/helpers_tests.rs b/crates/superposition_mcp/tests/helpers_tests.rs new file mode 100644 index 000000000..d51ae31e4 --- /dev/null +++ b/crates/superposition_mcp/tests/helpers_tests.rs @@ -0,0 +1,117 @@ +use aws_smithy_types::{Document, Number}; +use std::collections::HashMap; + +// We test the helper functions directly through the public module +use superposition_mcp::helpers::{ + doc_map_to_json, doc_to_json, format_datetime, json_to_doc, json_to_doc_map, +}; + +#[test] +fn test_json_to_doc_null() { + let doc = json_to_doc(serde_json::Value::Null); + assert!(matches!(doc, Document::Null)); +} + +#[test] +fn test_json_to_doc_bool() { + let doc = json_to_doc(serde_json::Value::Bool(true)); + assert!(matches!(doc, Document::Bool(true))); +} + +#[test] +fn test_json_to_doc_string() { + let doc = json_to_doc(serde_json::json!("hello")); + assert!(matches!(doc, Document::String(s) if s == "hello")); +} + +#[test] +fn test_json_to_doc_number_int() { + let doc = json_to_doc(serde_json::json!(42)); + match doc { + Document::Number(Number::NegInt(n)) => assert_eq!(n, 42), + _ => panic!("Expected NegInt, got {:?}", doc), + } +} + +#[test] +fn test_json_to_doc_number_float() { + let doc = json_to_doc(serde_json::json!(2.72)); + match doc { + Document::Number(Number::Float(f)) => assert!((f - 2.72).abs() < f64::EPSILON), + _ => panic!("Expected Float, got {:?}", doc), + } +} + +#[test] +fn test_json_to_doc_array() { + let doc = json_to_doc(serde_json::json!([1, "two", true])); + match doc { + Document::Array(arr) => assert_eq!(arr.len(), 3), + _ => panic!("Expected Array"), + } +} + +#[test] +fn test_json_to_doc_object() { + let doc = json_to_doc(serde_json::json!({"key": "value"})); + match doc { + Document::Object(map) => { + assert_eq!(map.len(), 1); + assert!(matches!(map.get("key"), Some(Document::String(s)) if s == "value")); + } + _ => panic!("Expected Object"), + } +} + +#[test] +fn test_doc_to_json_roundtrip() { + let original = serde_json::json!({ + "name": "test", + "count": 42, + "enabled": true, + "tags": ["a", "b"], + "nested": {"inner": "value"} + }); + let doc = json_to_doc(original.clone()); + let result = doc_to_json(&doc); + assert_eq!(original, result); +} + +#[test] +fn test_doc_map_to_json() { + let mut map = HashMap::new(); + map.insert("key".to_string(), Document::String("value".to_string())); + map.insert("num".to_string(), Document::Number(Number::PosInt(10))); + + let json = doc_map_to_json(&map); + assert!(json.is_object()); + let obj = json.as_object().unwrap(); + assert_eq!(obj.get("key").unwrap(), "value"); + assert_eq!(obj.get("num").unwrap(), 10); +} + +#[test] +fn test_json_to_doc_map_valid() { + let val = serde_json::json!({"a": 1, "b": "two"}); + let result = json_to_doc_map(val); + assert!(result.is_ok()); + let map = result.unwrap(); + assert_eq!(map.len(), 2); +} + +#[test] +fn test_json_to_doc_map_invalid() { + let val = serde_json::json!([1, 2, 3]); + let result = json_to_doc_map(val); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "Expected a JSON object"); +} + +#[test] +fn test_format_datetime() { + let dt = aws_smithy_types::DateTime::from_secs(1700000000); + let formatted = format_datetime(&dt); + // Should produce an ISO 8601 date string + assert!(formatted.contains("2023")); + assert!(formatted.contains("T")); +} diff --git a/crates/superposition_mcp/tests/server_tests.rs b/crates/superposition_mcp/tests/server_tests.rs new file mode 100644 index 000000000..99cea041f --- /dev/null +++ b/crates/superposition_mcp/tests/server_tests.rs @@ -0,0 +1,94 @@ +use superposition_mcp::SuperpositionMcpServer; +use superposition_mcp::config::McpServerConfig; + +fn test_config() -> McpServerConfig { + McpServerConfig { + endpoint_url: "http://localhost:8080".to_string(), + workspace_id: "test_workspace".to_string(), + org_id: "test_org".to_string(), + auth_token: None, + } +} + +fn test_config_with_token() -> McpServerConfig { + McpServerConfig { + auth_token: Some("test_token".to_string()), + ..test_config() + } +} + +#[test] +fn test_config_fields() { + let config = test_config(); + assert_eq!(config.endpoint_url, "http://localhost:8080"); + assert_eq!(config.workspace_id, "test_workspace"); + assert_eq!(config.org_id, "test_org"); + assert_eq!(config.auth_token, None); +} + +#[test] +fn test_config_with_auth_token() { + let config = test_config_with_token(); + assert_eq!(config.auth_token, Some("test_token".to_string())); +} + +#[test] +fn test_server_construction() { + let config = test_config(); + let server = SuperpositionMcpServer::new(config); + assert_eq!(server.config.workspace_id, "test_workspace"); + assert_eq!(server.config.org_id, "test_org"); +} + +#[test] +fn test_server_construction_with_token() { + let config = test_config_with_token(); + let server = SuperpositionMcpServer::new(config); + assert_eq!(server.config.auth_token, Some("test_token".to_string())); +} + +#[test] +fn test_server_info() { + use rmcp::ServerHandler; + + let server = SuperpositionMcpServer::new(test_config()); + let info = server.get_info(); + + assert!(info.instructions.is_some()); + let instructions = info.instructions.unwrap(); + assert!(instructions.contains("Superposition MCP Server")); + assert!(instructions.contains("feature flags")); +} + +#[test] +fn test_server_capabilities_include_tools() { + use rmcp::ServerHandler; + + let server = SuperpositionMcpServer::new(test_config()); + let info = server.get_info(); + + // The server should advertise tool capabilities + assert!(info.capabilities.tools.is_some()); +} + +#[test] +fn test_server_clone() { + let server = SuperpositionMcpServer::new(test_config()); + let cloned = server.clone(); + assert_eq!(cloned.config.workspace_id, server.config.workspace_id); + assert_eq!(cloned.config.org_id, server.config.org_id); +} + +#[test] +fn test_config_build_sdk_client() { + // Should not panic + let config = test_config(); + let _client = config.build_sdk_client(); +} + +#[test] +fn test_config_build_sdk_client_with_token() { + // Should not panic even with auth token + let config = test_config_with_token(); + let _client = config.build_sdk_client(); +} diff --git a/makefile b/makefile index 648787cfb..50d92469e 100644 --- a/makefile +++ b/makefile @@ -264,6 +264,14 @@ smithy-build: smithy-clean-build: smithy-clean smithy-build +# Build the MCP codegen plugin and install to local Maven +mcp-codegen-build: + cd smithy/mcp-codegen && gradle build publishToMavenLocal + +# Run MCP codegen standalone (without smithy CLI) +mcp-codegen-run: mcp-codegen-build + java -cp "$$(cd smithy/mcp-codegen && gradle dependencies --configuration runtimeClasspath -q 2>/dev/null | grep '^\---' | sed 's/.*--- //' | while read dep; do find ~/.gradle/caches -name "$$(echo $$dep | tr ':' '-' | sed 's/.*-\([^-]*\)$$//')*" 2>/dev/null; done | tr '\n' ':')smithy/mcp-codegen/build/libs/mcp-codegen-0.1.0.jar" io.superposition.mcp.codegen.McpCodegenRunner smithy/models crates/superposition_mcp/src/generated + smithy-clients: smithy-build ## Moving the Java client like this as smithy publishes it as a java project. ## Probably want to use that to publish it ourselves in the future. diff --git a/nix/rust.nix b/nix/rust.nix index 0f15748a8..ea1aeeb76 100644 --- a/nix/rust.nix +++ b/nix/rust.nix @@ -306,6 +306,27 @@ }; }; }; + "superposition_mcp" = { + imports = [ globalCrateConfig ]; + crane = { + args = { + buildInputs = + lib.optionals isDarwin ([ + pkgs.fixDarwinDylibNames + ]) + ++ [ + pkgs.postgresql_15 + pkgs.openssl + ]; + }; + extraBuildArgs = { + # https://discourse.nixos.org/t/how-to-use-install-name-tool-on-darwin/9931/2 + postInstall = '' + ${if isDarwin then "fixDarwinDylibNames" else ""} + ''; + }; + }; + }; "superposition" = { imports = [ globalCrateConfig ]; crane = { diff --git a/smithy/mcp-codegen/build.gradle.kts b/smithy/mcp-codegen/build.gradle.kts new file mode 100644 index 000000000..fa300ce9f --- /dev/null +++ b/smithy/mcp-codegen/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + `java-library` + `maven-publish` +} + +publishing { + publications { + create("maven") { + from(components["java"]) + } + } +} + +group = "io.superposition" +version = "0.1.0" + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +repositories { + mavenCentral() +} + +dependencies { + implementation("software.amazon.smithy:smithy-model:1.55.0") + implementation("software.amazon.smithy:smithy-build:1.55.0") + implementation("software.amazon.smithy:smithy-aws-traits:1.55.0") +} diff --git a/smithy/mcp-codegen/pom.xml b/smithy/mcp-codegen/pom.xml new file mode 100644 index 000000000..0aa05cfdb --- /dev/null +++ b/smithy/mcp-codegen/pom.xml @@ -0,0 +1,53 @@ + + + 4.0.0 + + io.superposition + mcp-codegen + 0.1.0 + jar + + Superposition MCP Codegen + Smithy build plugin that generates Rust MCP tool code from Smithy models + + + 17 + 17 + UTF-8 + 1.55.0 + + + + + software.amazon.smithy + smithy-model + ${smithy.version} + + + software.amazon.smithy + smithy-build + ${smithy.version} + + + software.amazon.smithy + smithy-aws-traits + ${smithy.version} + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 17 + 17 + + + + + diff --git a/smithy/mcp-codegen/settings.gradle.kts b/smithy/mcp-codegen/settings.gradle.kts new file mode 100644 index 000000000..bb936c596 --- /dev/null +++ b/smithy/mcp-codegen/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "mcp-codegen" diff --git a/smithy/mcp-codegen/src/main/java/io/superposition/mcp/codegen/McpCodegenPlugin.java b/smithy/mcp-codegen/src/main/java/io/superposition/mcp/codegen/McpCodegenPlugin.java new file mode 100644 index 000000000..4b9ff29e9 --- /dev/null +++ b/smithy/mcp-codegen/src/main/java/io/superposition/mcp/codegen/McpCodegenPlugin.java @@ -0,0 +1,46 @@ +package io.superposition.mcp.codegen; + +import software.amazon.smithy.build.PluginContext; +import software.amazon.smithy.build.SmithyBuildPlugin; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.ServiceShape; +import software.amazon.smithy.model.shapes.ShapeId; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.logging.Logger; + +/** + * Smithy build plugin that generates Rust MCP tool code from Smithy models. + * Reads operations, input/output shapes, and traits to deterministically produce: + * - Parameter structs (with Deserialize + JsonSchema derives) + * - Tool implementation methods (SDK builder calls) + * - Tool registration (#[tool_router] impl block) + * - Response formatting macros + */ +public class McpCodegenPlugin implements SmithyBuildPlugin { + + private static final Logger LOGGER = Logger.getLogger(McpCodegenPlugin.class.getName()); + + @Override + public String getName() { + return "mcp-rust-codegen"; + } + + @Override + public void execute(PluginContext context) { + Model model = context.getModel(); + ShapeId serviceId = ShapeId.from("io.superposition#Superposition"); + ServiceShape service = model.expectShape(serviceId, ServiceShape.class); + + Path outputDir = context.getFileManifest().getBaseDir(); + + LOGGER.info("MCP Codegen: Generating Rust MCP tools from Smithy model"); + + McpRustGenerator generator = new McpRustGenerator(model, service); + generator.generate(outputDir); + + LOGGER.info("MCP Codegen: Generation complete"); + } +} diff --git a/smithy/mcp-codegen/src/main/java/io/superposition/mcp/codegen/McpCodegenRunner.java b/smithy/mcp-codegen/src/main/java/io/superposition/mcp/codegen/McpCodegenRunner.java new file mode 100644 index 000000000..40490ebcc --- /dev/null +++ b/smithy/mcp-codegen/src/main/java/io/superposition/mcp/codegen/McpCodegenRunner.java @@ -0,0 +1,54 @@ +package io.superposition.mcp.codegen; + +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.ServiceShape; +import software.amazon.smithy.model.shapes.ShapeId; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * Standalone runner for the MCP codegen (alternative to using the Smithy CLI). + * Usage: java -cp io.superposition.mcp.codegen.McpCodegenRunner + */ +public class McpCodegenRunner { + + public static void main(String[] args) throws Exception { + if (args.length < 2) { + System.err.println("Usage: McpCodegenRunner "); + System.exit(1); + } + + Path modelsDir = Paths.get(args[0]); + Path outputDir = Paths.get(args[1]); + + if (!Files.isDirectory(modelsDir)) { + System.err.println("Models directory does not exist: " + modelsDir); + System.exit(1); + } + + Files.createDirectories(outputDir); + + System.out.println("Loading Smithy models from: " + modelsDir); + + // Build the model from all .smithy files + Model.Builder modelBuilder = Model.builder(); + Model model = Model.assembler() + .discoverModels() + .addImport(modelsDir) + .assemble() + .unwrap(); + + ShapeId serviceId = ShapeId.from("io.superposition#Superposition"); + ServiceShape service = model.expectShape(serviceId, ServiceShape.class); + + System.out.println("Found service: " + service.getId()); + System.out.println("Resources: " + service.getResources().size()); + + McpRustGenerator generator = new McpRustGenerator(model, service); + generator.generate(outputDir); + + System.out.println("MCP codegen complete! Output written to: " + outputDir); + } +} diff --git a/smithy/mcp-codegen/src/main/java/io/superposition/mcp/codegen/McpRustGenerator.java b/smithy/mcp-codegen/src/main/java/io/superposition/mcp/codegen/McpRustGenerator.java new file mode 100644 index 000000000..d36eadae1 --- /dev/null +++ b/smithy/mcp-codegen/src/main/java/io/superposition/mcp/codegen/McpRustGenerator.java @@ -0,0 +1,798 @@ +package io.superposition.mcp.codegen; + +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.knowledge.TopDownIndex; +import software.amazon.smithy.model.shapes.*; +import software.amazon.smithy.model.traits.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +/** + * Generates Rust MCP tool code from the Smithy model. + */ +public class McpRustGenerator { + + private static final Logger LOGGER = Logger.getLogger(McpRustGenerator.class.getName()); + + private final Model model; + private final ServiceShape service; + private final TopDownIndex topDownIndex; + + // Mixin member names to skip (handled implicitly by the MCP server) + private static final Set WORKSPACE_MIXIN_MEMBERS = Set.of( + "workspace_id", "org_id" + ); + + // Resources that use OrganisationMixin instead of WorkspaceMixin + private static final Set ORG_LEVEL_RESOURCES = Set.of( + "Organisation", "Workspace" + ); + + public McpRustGenerator(Model model, ServiceShape service) { + this.model = model; + this.service = service; + this.topDownIndex = TopDownIndex.of(model); + } + + public void generate(Path outputDir) { + try { + Path genDir = outputDir; + Files.createDirectories(genDir); + + // Group operations by resource + Map> resourceOps = collectOperations(); + + // Generate tool files per resource + for (var entry : resourceOps.entrySet()) { + String resourceName = entry.getKey(); + List ops = entry.getValue(); + String fileName = toSnakeCase(resourceName) + ".rs"; + + String content = generateToolFile(resourceName, ops); + Path filePath = genDir.resolve(fileName); + Files.writeString(filePath, content); + LOGGER.info("Generated: " + filePath); + } + + // Generate server_tools.rs (tool registration) + String serverTools = generateServerTools(resourceOps); + Files.writeString(genDir.resolve("server_tools.rs"), serverTools); + + // Generate response_macros.rs + String macros = generateResponseMacros(resourceOps); + Files.writeString(genDir.resolve("response_macros.rs"), macros); + + // Generate mod.rs + String modRs = generateModRs(resourceOps); + Files.writeString(genDir.resolve("mod.rs"), modRs); + + } catch (IOException e) { + throw new RuntimeException("Failed to generate MCP code", e); + } + } + + // ========== Operation Collection ========== + + private Map> collectOperations() { + Map> result = new LinkedHashMap<>(); + + for (var resourceId : service.getResources()) { + ResourceShape resource = model.expectShape(resourceId, ResourceShape.class); + String resourceName = resourceId.getName(); + List ops = new ArrayList<>(); + + // Collect CRUD operations + resource.getCreate().ifPresent(id -> addOp(ops, id, resourceName, "create")); + resource.getPut().ifPresent(id -> addOp(ops, id, resourceName, "put")); + resource.getRead().ifPresent(id -> addOp(ops, id, resourceName, "get")); + resource.getUpdate().ifPresent(id -> addOp(ops, id, resourceName, "update")); + resource.getDelete().ifPresent(id -> addOp(ops, id, resourceName, "delete")); + resource.getList().ifPresent(id -> addOp(ops, id, resourceName, "list")); + + // Collect additional operations + for (var opId : resource.getOperations()) { + String verb = inferVerb(opId.getName(), resourceName); + addOp(ops, opId, resourceName, verb); + } + + // Collect collection operations + for (var opId : resource.getCollectionOperations()) { + String verb = inferVerb(opId.getName(), resourceName); + addOp(ops, opId, resourceName, verb); + } + + if (!ops.isEmpty()) { + result.put(resourceName, ops); + } + } + + return result; + } + + private void addOp(List ops, ShapeId opId, String resourceName, String verb) { + OperationShape op = model.expectShape(opId, OperationShape.class); + ops.add(new OperationInfo(op, resourceName, verb)); + } + + private String inferVerb(String opName, String resourceName) { + // Remove resource name prefix to get verb + // e.g. "CreateVariable" -> "create", "ListExperiments" -> "list" + String lower = opName; + // Try to extract verb by removing resource-related suffix + String[] prefixes = {"Create", "Get", "List", "Update", "Delete", "Put", + "Conclude", "Discard", "Ramp", "Pause", "Resume", "Publish", + "Test", "Validate", "Move", "Rotate", "Add", "Remove"}; + for (String prefix : prefixes) { + if (lower.startsWith(prefix)) { + String rest = lower.substring(prefix.length()); + if (rest.isEmpty() || rest.equals(resourceName) || + rest.equals(resourceName + "s") || rest.equals("s")) { + return toSnakeCase(prefix); + } + return toSnakeCase(lower); + } + } + return toSnakeCase(lower); + } + + // ========== Tool File Generation ========== + + private String generateToolFile(String resourceName, List ops) { + StringBuilder sb = new StringBuilder(); + sb.append("// AUTO-GENERATED by smithy mcp-codegen — DO NOT EDIT\n"); + sb.append("use rmcp::model::*;\n"); + sb.append("use schemars::JsonSchema;\n"); + sb.append("use serde::Deserialize;\n\n"); + sb.append("use crate::SuperpositionMcpServer;\n"); + sb.append("use crate::helpers::*;\n\n"); + + // Generate param structs + for (OperationInfo op : ops) { + generateParamStruct(sb, op); + } + + // Generate impl block + sb.append("impl SuperpositionMcpServer {\n"); + for (OperationInfo op : ops) { + generateImplMethod(sb, op, resourceName); + } + sb.append("}\n"); + + return sb.toString(); + } + + private void generateParamStruct(StringBuilder sb, OperationInfo opInfo) { + OperationShape op = opInfo.operation; + Optional inputId = op.getInput(); + if (inputId.isEmpty()) return; + + StructureShape input = model.expectShape(inputId.get(), StructureShape.class); + List members = getInputMembers(input); + + if (members.isEmpty()) return; + + String structName = opInfo.paramStructName(); + + // Doc comment from operation + op.getTrait(DocumentationTrait.class).ifPresent(doc -> + sb.append("/// ").append(doc.getValue().replace("\n", "\n/// ")).append("\n") + ); + + sb.append("#[derive(Debug, Deserialize, JsonSchema)]\n"); + sb.append("pub struct ").append(structName).append(" {\n"); + + for (MemberInfo m : members) { + // Doc comment + m.member.getTrait(DocumentationTrait.class).ifPresent(doc -> + sb.append(" /// ").append(doc.getValue().replace("\n", "\n /// ")).append("\n") + ); + + String rustType = toRustType(m.member, m.isRequired); + String fieldName = toRustFieldName(m.member.getMemberName()); + sb.append(" pub ").append(fieldName).append(": ").append(rustType).append(",\n"); + } + sb.append("}\n\n"); + + // Generate any nested helper structs (e.g., VariantParam) + generateNestedStructs(sb, opInfo, members); + } + + private void generateNestedStructs(StringBuilder sb, OperationInfo opInfo, List members) { + for (MemberInfo m : members) { + Shape target = model.expectShape(m.member.getTarget()); + if (target.isListShape()) { + ListShape list = target.asListShape().get(); + Shape memberTarget = model.expectShape(list.getMember().getTarget()); + if (memberTarget.isStructureShape()) { + StructureShape nested = memberTarget.asStructureShape().get(); + String nestedName = opInfo.shortName() + toPascalCase(m.member.getMemberName()) + "Item"; + sb.append("#[derive(Debug, Deserialize, JsonSchema)]\n"); + sb.append("pub struct ").append(nestedName).append(" {\n"); + for (var nm : nested.getAllMembers().values()) { + String rustType = toRustType(nm, nm.hasTrait(RequiredTrait.class)); + String fieldName = toRustFieldName(nm.getMemberName()); + sb.append(" pub ").append(fieldName).append(": ").append(rustType).append(",\n"); + } + sb.append("}\n\n"); + } + } + } + } + + private void generateImplMethod(StringBuilder sb, OperationInfo opInfo, String resourceName) { + OperationShape op = opInfo.operation; + String methodName = opInfo.implMethodName(); + String paramStruct = opInfo.paramStructName(); + boolean hasInput = op.getInput().isPresent(); + Optional outputId = op.getOutput(); + + List members = Collections.emptyList(); + StructureShape inputShape = null; + if (hasInput) { + inputShape = model.expectShape(op.getInput().get(), StructureShape.class); + members = getInputMembers(inputShape); + } + + // Check if this is a paginated list operation + boolean isPaginated = opInfo.verb.equals("list") && outputId.isPresent() && + hasPaginatedResponse(outputId.get()); + + // Check if the operation has a @httpPayload member + MemberInfo payloadMember = null; + List headerMembers = new ArrayList<>(); + List bodyMembers = new ArrayList<>(); + + if (inputShape != null) { + for (var rawMember : inputShape.getAllMembers().values()) { + if (rawMember.hasTrait(HttpPayloadTrait.class)) { + // Find this in our filtered members + for (MemberInfo mi : members) { + // The payload itself is a separate type; we flatten its fields into params + } + payloadMember = new MemberInfo(rawMember, rawMember.hasTrait(RequiredTrait.class)); + } + } + + for (MemberInfo mi : members) { + if (mi.member.hasTrait(HttpHeaderTrait.class)) { + headerMembers.add(mi); + } else { + bodyMembers.add(mi); + } + } + } + + boolean isOrgLevel = ORG_LEVEL_RESOURCES.contains(resourceName); + + // Method signature + if (members.isEmpty()) { + sb.append(" pub async fn ").append(methodName) + .append("(&self) -> Result {\n"); + } else { + sb.append(" pub async fn ").append(methodName) + .append("(&self, args: ").append(paramStruct) + .append(") -> Result {\n"); + } + + // Build SDK call + String sdkMethod = toSnakeCase(op.getId().getName()); + sb.append(" let mut req = self.client.").append(sdkMethod).append("()\n"); + + if (!isOrgLevel || resourceName.equals("Workspace")) { + sb.append(" .workspace_id(&self.config.workspace_id)\n"); + } + sb.append(" .org_id(&self.config.org_id);\n"); + + // Chain required body fields + for (MemberInfo m : bodyMembers) { + if (!m.isRequired) continue; + generateFieldSetter(sb, m, opInfo, false); + } + + // Chain optional body fields + for (MemberInfo m : bodyMembers) { + if (m.isRequired) continue; + generateFieldSetter(sb, m, opInfo, true); + } + + // Chain header fields (always optional in params) + for (MemberInfo m : headerMembers) { + generateFieldSetter(sb, m, opInfo, true); + } + + // Send and handle response + sb.append(" let resp = req.send().await.map_err(mcp_err)?;\n"); + + if (isPaginated) { + String macroName = toSnakeCase(resourceName) + "_to_json"; + sb.append(" let items: Vec = resp.data.iter().map(|r| ") + .append(macroName).append("!(r)).collect();\n"); + sb.append(" let result = serde_json::json!({\n"); + sb.append(" \"total_pages\": resp.total_pages,\n"); + sb.append(" \"total_items\": resp.total_items,\n"); + sb.append(" \"data\": items,\n"); + sb.append(" });\n"); + sb.append(" let json = serde_json::to_string_pretty(&result).map_err(mcp_err)?;\n"); + } else if (op.hasTrait(HttpTrait.class) && + op.expectTrait(HttpTrait.class).getMethod().equals("DELETE") && + outputId.isEmpty()) { + sb.append(" let json = \"Deleted successfully\".to_string();\n"); + } else if (outputId.isPresent()) { + String macroName = toSnakeCase(resourceName) + "_to_json"; + sb.append(" let json = serde_json::to_string_pretty(&") + .append(macroName).append("!(resp)).map_err(mcp_err)?;\n"); + } else { + sb.append(" let json = \"Success\".to_string();\n"); + } + + sb.append(" Ok(CallToolResult::success(vec![Content::text(json)]))\n"); + sb.append(" }\n\n"); + } + + private void generateFieldSetter(StringBuilder sb, MemberInfo m, OperationInfo opInfo, boolean isOptional) { + String fieldName = toRustFieldName(m.member.getMemberName()); + String sdkMethodName = toSnakeCase(m.member.getMemberName()); + Shape target = model.expectShape(m.member.getTarget()); + + // Determine the type category + TypeCategory cat = categorizeType(target); + + if (isOptional) { + sb.append(" if let Some(v) = args.").append(fieldName).append(" {\n"); + switch (cat) { + case STRING, INTEGER, BOOLEAN: + sb.append(" req = req.").append(sdkMethodName).append("(v);\n"); + break; + case DOCUMENT_MAP: + sb.append(" req = req.set_").append(sdkMethodName) + .append("(Some(json_to_doc_map(v).map_err(mcp_err)?));\n"); + break; + case DOCUMENT: + sb.append(" req = req.").append(sdkMethodName) + .append("(json_to_doc(v));\n"); + break; + case STRING_LIST: + sb.append(" for item in v {\n"); + sb.append(" req = req.").append(sdkMethodName).append("(item);\n"); + sb.append(" }\n"); + break; + case ENUM: + generateEnumConversion(sb, target, sdkMethodName, "v", " "); + break; + default: + sb.append(" req = req.").append(sdkMethodName).append("(v);\n"); + break; + } + sb.append(" }\n"); + } else { + switch (cat) { + case STRING, INTEGER, BOOLEAN: + sb.append(" req = req.").append(sdkMethodName).append("(args.") + .append(fieldName).append(");\n"); + break; + case DOCUMENT_MAP: + sb.append(" req = req.set_").append(sdkMethodName) + .append("(Some(json_to_doc_map(args.").append(fieldName) + .append(").map_err(mcp_err)?));\n"); + break; + case DOCUMENT: + sb.append(" req = req.").append(sdkMethodName) + .append("(json_to_doc(args.").append(fieldName).append("));\n"); + break; + case STRING_LIST: + sb.append(" for item in args.").append(fieldName).append(" {\n"); + sb.append(" req = req.").append(sdkMethodName).append("(item);\n"); + sb.append(" }\n"); + break; + default: + sb.append(" req = req.").append(sdkMethodName).append("(args.") + .append(fieldName).append(");\n"); + break; + } + } + } + + private void generateEnumConversion(StringBuilder sb, Shape enumShape, String sdkMethod, + String varName, String indent) { + String enumTypeName = enumShape.getId().getName(); + String sdkEnumPath = "superposition_sdk::types::" + enumTypeName; + if (enumShape.isEnumShape()) { + EnumShape es = enumShape.asEnumShape().get(); + sb.append(indent).append("let parsed = match ").append(varName) + .append(".to_uppercase().as_str() {\n"); + for (var entry : es.getEnumValues().entrySet()) { + String memberName = entry.getKey(); + String value = entry.getValue(); + sb.append(indent).append(" \"").append(value.toUpperCase()) + .append("\" => ").append(sdkEnumPath).append("::") + .append(memberName).append(",\n"); + } + // Default to first variant + String firstMember = es.getEnumValues().keySet().iterator().next(); + sb.append(indent).append(" _ => ").append(sdkEnumPath).append("::") + .append(firstMember).append(",\n"); + sb.append(indent).append("};\n"); + sb.append(indent).append("req = req.").append(sdkMethod).append("(parsed);\n"); + } else { + // String enum - just pass through + sb.append(indent).append("req = req.").append(sdkMethod).append("(") + .append(varName).append(");\n"); + } + } + + // ========== Server Tools Registration ========== + + private String generateServerTools(Map> resourceOps) { + StringBuilder sb = new StringBuilder(); + sb.append("// AUTO-GENERATED by smithy mcp-codegen — DO NOT EDIT\n"); + sb.append("use rmcp::handler::server::wrapper::Parameters;\n"); + sb.append("use rmcp::model::*;\n"); + sb.append("use rmcp::{tool, tool_router};\n\n"); + sb.append("use crate::SuperpositionMcpServer;\n"); + sb.append("use crate::generated::tools::*;\n\n"); + + sb.append("#[tool_router]\n"); + sb.append("impl SuperpositionMcpServer {\n"); + + for (var entry : resourceOps.entrySet()) { + String resourceName = entry.getKey(); + sb.append(" // ===== ").append(resourceName).append(" =====\n"); + + for (OperationInfo op : entry.getValue()) { + String toolName = op.toolName(); + String description = op.operation.getTrait(DocumentationTrait.class) + .map(DocumentationTrait::getValue) + .orElse("No description available.") + .replace("\"", "\\\""); + // Truncate long descriptions + if (description.length() > 200) { + description = description.substring(0, 197) + "..."; + } + + String methodName = op.registrationMethodName(); + String implMethod = op.implMethodName(); + String paramStruct = op.paramStructName(); + boolean hasParams = op.operation.getInput().isPresent() && + !getInputMembers(model.expectShape(op.operation.getInput().get(), + StructureShape.class)).isEmpty(); + + sb.append(" #[tool(\n"); + sb.append(" name = \"").append(toolName).append("\",\n"); + sb.append(" description = \"").append(description).append("\"\n"); + sb.append(" )]\n"); + + if (hasParams) { + sb.append(" async fn ").append(methodName).append("(\n"); + sb.append(" &self,\n"); + sb.append(" Parameters(args): Parameters<").append(paramStruct).append(">,\n"); + sb.append(" ) -> Result {\n"); + sb.append(" self.").append(implMethod).append("(args).await\n"); + } else { + sb.append(" async fn ").append(methodName) + .append("(&self) -> Result {\n"); + sb.append(" self.").append(implMethod).append("().await\n"); + } + sb.append(" }\n\n"); + } + } + + sb.append("}\n"); + return sb.toString(); + } + + // ========== Response Macros ========== + + private String generateResponseMacros(Map> resourceOps) { + StringBuilder sb = new StringBuilder(); + sb.append("// AUTO-GENERATED by smithy mcp-codegen — DO NOT EDIT\n\n"); + + Set generatedMacros = new HashSet<>(); + + for (var entry : resourceOps.entrySet()) { + String resourceName = entry.getKey(); + String macroName = toSnakeCase(resourceName) + "_to_json"; + + if (generatedMacros.contains(macroName)) continue; + generatedMacros.add(macroName); + + // Find the primary response shape for this resource + // Usually from the Get or Create operation + ShapeId responseShapeId = findResponseShape(entry.getValue()); + if (responseShapeId == null) continue; + + Shape responseShape = model.expectShape(responseShapeId); + if (!responseShape.isStructureShape()) continue; + + StructureShape resp = responseShape.asStructureShape().get(); + + sb.append("macro_rules! ").append(macroName).append(" {\n"); + sb.append(" ($r:expr) => {{\n"); + sb.append(" serde_json::json!({\n"); + + for (var member : resp.getAllMembers().values()) { + String name = member.getMemberName(); + Shape target = model.expectShape(member.getTarget()); + String jsonExpr = toJsonExpression("$r", name, target, member); + sb.append(" \"").append(name).append("\": ").append(jsonExpr).append(",\n"); + } + + sb.append(" })\n"); + sb.append(" }}\n"); + sb.append("}\n\n"); + sb.append("pub(crate) use ").append(macroName).append(";\n\n"); + } + + return sb.toString(); + } + + private ShapeId findResponseShape(List ops) { + // Prefer Get/Read operation output, then Create, then first available + for (String verb : List.of("get", "create", "update", "list")) { + for (OperationInfo op : ops) { + if (op.verb.equals(verb) && op.operation.getOutput().isPresent()) { + ShapeId outId = op.operation.getOutput().get(); + StructureShape outShape = model.expectShape(outId, StructureShape.class); + // For list operations, skip the paginated wrapper + if (verb.equals("list")) continue; + return outId; + } + } + } + // Fallback: first operation with output + for (OperationInfo op : ops) { + if (op.operation.getOutput().isPresent()) { + return op.operation.getOutput().get(); + } + } + return null; + } + + private String toJsonExpression(String rootVar, String fieldName, Shape target, MemberShape member) { + String accessor = rootVar + "." + toRustFieldName(fieldName); + + if (target.isTimestampShape()) { + if (member.hasTrait(RequiredTrait.class)) { + return "$crate::helpers::format_datetime(&" + accessor + ")"; + } else { + return accessor + ".as_ref().map($crate::helpers::format_datetime)"; + } + } + + if (isDocumentMap(target)) { + if (member.hasTrait(RequiredTrait.class)) { + return "$crate::helpers::doc_map_to_json(&" + accessor + ")"; + } else { + return accessor + ".as_ref().map($crate::helpers::doc_map_to_json)"; + } + } + + if (target.isDocumentShape()) { + if (member.hasTrait(RequiredTrait.class)) { + return "$crate::helpers::doc_to_json(&" + accessor + ")"; + } else { + return accessor + ".as_ref().map($crate::helpers::doc_to_json)"; + } + } + + if (target.isEnumShape() || target.isUnionShape()) { + return "format!(\"{:?}\", " + accessor + ")"; + } + + // Default: direct access (works for String, Integer, Boolean, Option) + return accessor; + } + + // ========== Mod.rs Generation ========== + + private String generateModRs(Map> resourceOps) { + StringBuilder sb = new StringBuilder(); + sb.append("// AUTO-GENERATED by smithy mcp-codegen — DO NOT EDIT\n\n"); + + sb.append("pub mod tools {\n"); + for (String resourceName : resourceOps.keySet()) { + String modName = toSnakeCase(resourceName); + sb.append(" mod ").append(modName).append(";\n"); + sb.append(" pub use ").append(modName).append("::*;\n"); + } + sb.append("}\n\n"); + + sb.append("#[macro_use]\n"); + sb.append("mod response_macros;\n\n"); + + sb.append("mod server_tools;\n"); + sb.append("pub use server_tools::*;\n"); + + return sb.toString(); + } + + // ========== Helper Methods ========== + + private List getInputMembers(StructureShape input) { + List result = new ArrayList<>(); + + for (var member : input.getAllMembers().values()) { + String name = member.getMemberName(); + + // Skip workspace/org mixin members + if (WORKSPACE_MIXIN_MEMBERS.contains(name)) continue; + + // Check for @httpPayload — if present, flatten the payload structure + if (member.hasTrait(HttpPayloadTrait.class)) { + Shape payloadTarget = model.expectShape(member.getTarget()); + if (payloadTarget.isStructureShape()) { + StructureShape payloadStruct = payloadTarget.asStructureShape().get(); + for (var payloadMember : payloadStruct.getAllMembers().values()) { + boolean required = payloadMember.hasTrait(RequiredTrait.class); + result.add(new MemberInfo(payloadMember, required)); + } + } + continue; + } + + boolean required = member.hasTrait(RequiredTrait.class); + + // @httpLabel members are always required + if (member.hasTrait(HttpLabelTrait.class)) { + required = true; + } + + result.add(new MemberInfo(member, required)); + } + + return result; + } + + private boolean hasPaginatedResponse(ShapeId outputId) { + StructureShape output = model.expectShape(outputId, StructureShape.class); + return output.getMember("total_pages").isPresent() && + output.getMember("data").isPresent(); + } + + private boolean isDocumentMap(Shape shape) { + if (shape.isMapShape()) { + MapShape map = shape.asMapShape().get(); + Shape valueTarget = model.expectShape(map.getValue().getTarget()); + return valueTarget.isDocumentShape(); + } + return false; + } + + enum TypeCategory { + STRING, INTEGER, BOOLEAN, DOCUMENT, DOCUMENT_MAP, STRING_LIST, STRUCT_LIST, ENUM, OTHER + } + + private TypeCategory categorizeType(Shape target) { + if (target.isStringShape()) return TypeCategory.STRING; + if (target.isIntegerShape() || target.isLongShape()) return TypeCategory.INTEGER; + if (target.isBooleanShape()) return TypeCategory.BOOLEAN; + if (target.isDocumentShape()) return TypeCategory.DOCUMENT; + if (isDocumentMap(target)) return TypeCategory.DOCUMENT_MAP; + if (target.isEnumShape()) return TypeCategory.ENUM; + if (target.isListShape()) { + ListShape list = target.asListShape().get(); + Shape memberTarget = model.expectShape(list.getMember().getTarget()); + if (memberTarget.isStringShape() || memberTarget.isEnumShape()) { + return TypeCategory.STRING_LIST; + } + if (memberTarget.isStructureShape()) return TypeCategory.STRUCT_LIST; + } + if (target.isMapShape()) return TypeCategory.DOCUMENT_MAP; + return TypeCategory.OTHER; + } + + private String toRustType(MemberShape member, boolean required) { + Shape target = model.expectShape(member.getTarget()); + String baseType = toRustBaseType(target); + if (required) { + return baseType; + } + return "Option<" + baseType + ">"; + } + + private String toRustBaseType(Shape target) { + if (target.isStringShape() || target.isEnumShape()) return "String"; + if (target.isIntegerShape()) return "i32"; + if (target.isLongShape()) return "i64"; + if (target.isBooleanShape()) return "bool"; + if (target.isDocumentShape()) return "serde_json::Value"; + if (isDocumentMap(target) || target.isMapShape()) return "serde_json::Value"; + if (target.isTimestampShape()) return "String"; + if (target.isListShape()) { + ListShape list = target.asListShape().get(); + Shape memberTarget = model.expectShape(list.getMember().getTarget()); + return "Vec<" + toRustBaseType(memberTarget) + ">"; + } + if (target.isStructureShape()) return "serde_json::Value"; + if (target.isUnionShape()) return "serde_json::Value"; + return "serde_json::Value"; + } + + private String toRustFieldName(String name) { + String snake = toSnakeCase(name); + // Handle Rust keywords + if (snake.equals("override")) return "r#override"; + if (snake.equals("type")) return "r#type"; + if (snake.equals("match")) return "r#match"; + return snake; + } + + static String toSnakeCase(String name) { + if (name == null || name.isEmpty()) return name; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (Character.isUpperCase(c)) { + if (i > 0) sb.append('_'); + sb.append(Character.toLowerCase(c)); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + static String toPascalCase(String snake) { + StringBuilder sb = new StringBuilder(); + boolean nextUpper = true; + for (char c : snake.toCharArray()) { + if (c == '_') { + nextUpper = true; + } else if (nextUpper) { + sb.append(Character.toUpperCase(c)); + nextUpper = false; + } else { + sb.append(c); + } + } + return sb.toString(); + } + + // ========== Inner Types ========== + + static class MemberInfo { + final MemberShape member; + final boolean isRequired; + + MemberInfo(MemberShape member, boolean isRequired) { + this.member = member; + this.isRequired = isRequired; + } + } + + static class OperationInfo { + final OperationShape operation; + final String resourceName; + final String verb; + + OperationInfo(OperationShape operation, String resourceName, String verb) { + this.operation = operation; + this.resourceName = resourceName; + this.verb = verb; + } + + String toolName() { + return McpRustGenerator.toSnakeCase(resourceName) + "." + verb; + } + + String paramStructName() { + return toPascalCase(verb) + toPascalCase(McpRustGenerator.toSnakeCase(resourceName)) + "Params"; + } + + String implMethodName() { + return verb + "_" + McpRustGenerator.toSnakeCase(resourceName) + "_impl"; + } + + String registrationMethodName() { + return McpRustGenerator.toSnakeCase(resourceName) + "_" + verb; + } + + String shortName() { + return toPascalCase(verb) + resourceName; + } + } +} diff --git a/smithy/mcp-codegen/src/main/resources/META-INF/services/software.amazon.smithy.build.SmithyBuildPlugin b/smithy/mcp-codegen/src/main/resources/META-INF/services/software.amazon.smithy.build.SmithyBuildPlugin new file mode 100644 index 000000000..1bddf3a9b --- /dev/null +++ b/smithy/mcp-codegen/src/main/resources/META-INF/services/software.amazon.smithy.build.SmithyBuildPlugin @@ -0,0 +1 @@ +io.superposition.mcp.codegen.McpCodegenPlugin diff --git a/smithy/smithy-build.json b/smithy/smithy-build.json index 9138bf09a..520c14b9f 100644 --- a/smithy/smithy-build.json +++ b/smithy/smithy-build.json @@ -18,7 +18,8 @@ "software.amazon.smithy.java:client-core:0.0.1", "software.amazon.smithy.java.codegen:plugins:0.0.1", "in.juspay.smithy.haskell:client-codegen:0.0.5", - "software.amazon.smithy.java:aws-client-restjson:0.0.1" + "software.amazon.smithy.java:aws-client-restjson:0.0.1", + "io.superposition:mcp-codegen:0.1.0" ] }, "plugins": { @@ -61,6 +62,7 @@ "version": "0.0.1", "service": "io.superposition#Superposition" }, + "mcp-rust-codegen": {}, "openapi": { "service": "io.superposition#Superposition", "protocol": "aws.protocols#restJson1",