diff --git a/Cargo.lock b/Cargo.lock index c3c4462..6ee3950 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,9 +84,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.13.3" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c953fe1ba023e6b7730c0d4b031d06f267f23a46167dcbd40316644b10a17ba" +checksum = "94b8ff6c09cd57b16da53641caa860168b88c172a5ee163b0288d3d6eea12786" dependencies = [ "aws-lc-sys", "zeroize", @@ -94,9 +94,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.30.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbfd150b5dbdb988bcc8fb1fe787eb6b7ee6180ca24da683b61ea5405f3d43ff" +checksum = "0e44d16778acaf6a9ec9899b92cebd65580b83f685446bf2e1f5d3d732f99dcd" dependencies = [ "bindgen", "cc", @@ -216,32 +216,29 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bindgen" -version = "0.69.5" +version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ "bitflags", "cexpr", "clang-sys", "itertools", - "lazy_static", - "lazycell", "log", "prettyplease", "proc-macro2", "quote", "regex", - "rustc-hash 1.1.0", + "rustc-hash", "shlex", "syn", - "which", ] [[package]] name = "bitflags" -version = "2.9.3" +version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" [[package]] name = "bumpalo" @@ -257,9 +254,9 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "cc" -version = "1.2.35" +version = "1.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "590f9024a68a8c40351881787f1934dc11afd69090f5edb6831464694d836ea3" +checksum = "65193589c6404eb80b450d618eaf9a2cafaaafd57ecce47370519ef674a7bd44" dependencies = [ "find-msvc-tools", "jobserver", @@ -427,16 +424,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" -[[package]] -name = "errno" -version = "0.3.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" -dependencies = [ - "libc", - "windows-sys 0.60.2", -] - [[package]] name = "event-listener" version = "2.5.3" @@ -454,9 +441,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e178e4fba8a2726903f6ba98a6d221e76f9c12c650d5dc0e6afdc50677b49650" +checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d" [[package]] name = "fnv" @@ -475,9 +462,9 @@ dependencies = [ [[package]] name = "fs-err" -version = "3.1.1" +version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d7be93788013f265201256d58f04936a8079ad5dc898743aa20525f503b683" +checksum = "44f150ffc8782f35521cec2b23727707cb4045706ba3c854e86bef66b3a8cdbd" dependencies = [ "autocfg", "tokio", @@ -633,7 +620,7 @@ dependencies = [ "js-sys", "libc", "r-efi", - "wasi 0.14.3+wasi-0.2.4", + "wasi 0.14.7+wasi-0.2.4", "wasm-bindgen", ] @@ -753,15 +740,6 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" -[[package]] -name = "home" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" -dependencies = [ - "windows-sys 0.59.0", -] - [[package]] name = "http" version = "0.2.12" @@ -917,9 +895,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" dependencies = [ "base64 0.22.1", "bytes", @@ -1048,9 +1026,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.11.0" +version = "2.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9" +checksum = "92119844f513ffa41556430369ab02c295a3578af21cf945caa3e9e0c2481ac3" dependencies = [ "equivalent", "hashbrown", @@ -1100,9 +1078,9 @@ dependencies = [ [[package]] name = "itertools" -version = "0.12.1" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] @@ -1125,9 +1103,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "852f13bec5eba4ba9afbeb93fd7c13fe56147f055939ae21c43a29a0ecb2702e" dependencies = [ "once_cell", "wasm-bindgen", @@ -1139,12 +1117,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "lazycell" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" - [[package]] name = "libc" version = "0.2.175" @@ -1161,12 +1133,6 @@ dependencies = [ "windows-targets 0.53.3", ] -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - [[package]] name = "litemap" version = "0.8.0" @@ -1191,9 +1157,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.27" +version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "lru-slab" @@ -1431,7 +1397,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash 2.1.1", + "rustc-hash", "rustls", "socket2 0.6.0", "thiserror 2.0.16", @@ -1451,7 +1417,7 @@ dependencies = [ "lru-slab", "rand 0.9.2", "ring", - "rustc-hash 2.1.1", + "rustc-hash", "rustls", "rustls-pki-types", "slab", @@ -1676,9 +1642,9 @@ dependencies = [ [[package]] name = "rust-mcp-schema" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "098436b06bfa4b88b110d12a5567cf37fd454735ee67cab7eb48bdbea0dd0e57" +checksum = "0bb65fd293dbbfabaacba1512b3948cdd9bf31ad1f2c0fed4962052b590c5c44" dependencies = [ "serde", "serde_json", @@ -1733,31 +1699,12 @@ version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" -[[package]] -name = "rustc-hash" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" - [[package]] name = "rustc-hash" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.59.0", -] - [[package]] name = "rustls" version = "0.23.31" @@ -1794,9 +1741,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.4" +version = "0.103.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" +checksum = "8572f3c2cb9934231157b45499fc41e1f58c589fdfb81a844ba873265e80f8eb" dependencies = [ "aws-lc-rs", "ring", @@ -1824,18 +1771,28 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.225" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd6c24dee235d0da097043389623fb913daddf92c76e9f5a1db88607a0bcbd1d" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.225" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "659356f9a0cb1e529b24c01e43ad2bdf520ec4ceaf83047b83ddcc2251f96383" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.225" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "0ea936adf78b1f766949a4977b91d2f5595825bd6ec079aa9543ad2685fc4516" dependencies = [ "proc-macro2", "quote", @@ -1844,24 +1801,26 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.143" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "itoa", "memchr", "ryu", "serde", + "serde_core", ] [[package]] name = "serde_path_to_error" -version = "0.1.17" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" dependencies = [ "itoa", "serde", + "serde_core", ] [[package]] @@ -2129,9 +2088,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.42" +version = "0.3.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca967379f9d8eb8058d86ed467d81d03e81acd45757e4ca341c24affbe8e8e3" +checksum = "83bde6f1ec10e72d583d91623c939f623002284ef622b87de38cfd546cbf2031" dependencies = [ "deranged", "num-conv", @@ -2143,15 +2102,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9108bb380861b07264b950ded55a44a14a4adc68b9f5efd85aafc3aa4d40a68" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" [[package]] name = "time-macros" -version = "0.2.23" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7182799245a7264ce590b349d90338f1c1affad93d2639aed5f8f69c090b334c" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" dependencies = [ "num-conv", "time-core", @@ -2215,9 +2174,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.2" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +checksum = "05f63835928ca123f1bef57abbcd23bb2ba0ac9ae1235f1e65bda0d06e7786bd" dependencies = [ "rustls", "tokio", @@ -2369,9 +2328,9 @@ checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" [[package]] name = "untrusted" @@ -2399,9 +2358,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.18.0" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f33196643e165781c20a5ead5582283a7dacbb87855d867fbc2df3f81eddc1be" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ "getrandom 0.3.3", "js-sys", @@ -2449,30 +2408,40 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasi" -version = "0.14.3+wasi-0.2.4" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a51ae83037bdd272a9e28ce236db8c07016dd0d50c27038b3f407533c030c95" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "ab10a69fbd0a177f5f649ad4d8d3305499c42bab9aef2f7ff592d0ec8f833819" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.100" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +checksum = "0bb702423545a6007bbc368fde243ba47ca275e549c8a28617f56f6ba53b1d1c" dependencies = [ "bumpalo", "log", @@ -2484,9 +2453,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.50" +version = "0.4.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "a0b221ff421256839509adbb55998214a70d829d3a28c69b4a6672e9d2a42f67" dependencies = [ "cfg-if", "js-sys", @@ -2497,9 +2466,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "fc65f4f411d91494355917b605e1480033152658d71f722a90647f56a70c88a0" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2507,9 +2476,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "ffc003a991398a8ee604a401e194b6b3a39677b3173d6e74495eb51b82e99a32" dependencies = [ "proc-macro2", "quote", @@ -2520,9 +2489,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "293c37f4efa430ca14db3721dfbe48d8c33308096bd44d80ebaa775ab71ba1cf" dependencies = [ "unicode-ident", ] @@ -2542,9 +2511,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.77" +version = "0.3.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "fbe734895e869dc429d78c4b433f8d17d95f8d05317440b4fad5ab2d33e596dc" dependencies = [ "js-sys", "wasm-bindgen", @@ -2569,18 +2538,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "which" -version = "4.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" -dependencies = [ - "either", - "home", - "once_cell", - "rustix", -] - [[package]] name = "windows-link" version = "0.1.3" @@ -2767,9 +2724,9 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "052283831dbae3d879dc7f51f3d92703a316ca49f91540417d38591826127814" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "writeable" @@ -2803,18 +2760,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.26" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.26" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", diff --git a/README.md b/README.md index 51c3b49..2c70c3e 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,8 @@ This project supports following transports: - ✅ Batch Messages - ✅ Streaming & non-streaming JSON response - ✅ Streamable HTTP Support for MCP Clients -- ⬜ Resumability -- ⬜ Authentication / Oauth +- ✅ Resumability +- ⬜ Oauth Authentication **⚠️** Project is currently under development and should be used at your own risk. @@ -50,6 +50,7 @@ This project supports following transports: - [MCP Client (stdio)](#mcp-client-stdio) - [MCP Client (Streamable HTTP)](#mcp-client_streamable-http)) - [MCP Client (sse)](#mcp-client-sse) +- [Macros](#macros) - [Getting Started](#getting-started) - [HyperServerOptions](#hyperserveroptions) - [Security Considerations](#security-considerations) @@ -386,6 +387,114 @@ Creating an MCP client using the `rust-mcp-sdk` with the SSE transport is almost 👉 see [examples/simple-mcp-client-sse](https://github.com/rust-mcp-stack/rust-mcp-sdk/tree/main/examples/simple-mcp-client-sse) for a complete working example. +## Macros +[rust-mcp-sdk](https://github.com/rust-mcp-stack/rust-mcp-sdk) includes several helpful macros that simplify common tasks when building MCP servers and clients. For example, they can automatically generate tool specifications and tool schemas right from your structs, or assist with elicitation requests and responses making them completely type safe. + +> To use these macros, ensure the `macros` feature is enabled in your Cargo.toml. + +### mcp_tool +`mcp_tool` is a procedural macro attribute that helps generating rust_mcp_schema::Tool from a struct. + +Usage example: +```rust +#[mcp_tool( + name = "move_file", + title="Move File", + description = concat!("Move or rename files and directories. Can move files between directories ", +"and rename them in a single operation. If the destination exists, the ", +"operation will fail. Works across different directories and can be used ", +"for simple renaming within the same directory. ", +"Both source and destination must be within allowed directories."), + destructive_hint = false, + idempotent_hint = false, + open_world_hint = false, + read_only_hint = false +)] +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug, JsonSchema)] +pub struct MoveFileTool { + /// The source path of the file to move. + pub source: String, + /// The destination path to move the file to. + pub destination: String, +} + +// Now we can call `tool()` method on it to get a Tool instance +let rust_mcp_sdk::schema::Tool = MoveFileTool::tool(); + +``` + +💻 For a real-world example, check out any of the tools available at: https://github.com/rust-mcp-stack/rust-mcp-filesystem/tree/main/src/tools + + +### tool_box +`tool_box` generates an enum from a provided list of tools, making it easier to organize and manage them, especially when your application includes a large number of tools. + +It accepts an array of tools and generates an enum where each tool becomes a variant of the enum. + +Generated enum has a `tools()` function that returns a `Vec` , and a `TryFrom` trait implementation that could be used to convert a ToolRequest into a Tool instance. + +Usage example: +```rust + // Accepts an array of tools and generates an enum named `FileSystemTools`, + // where each tool becomes a variant of the enum. + tool_box!(FileSystemTools, [ReadFileTool, MoveFileTool, SearchFilesTool]); + + // now in the app, we can use the FileSystemTools, like: + let all_tools: Vec = FileSystemTools::tools(); +``` + +💻 To see a real-world example of that please see : +- `tool_box` macro usage: [https://github.com/rust-mcp-stack/rust-mcp-filesystem/blob/main/src/tools.rs](https://github.com/rust-mcp-stack/rust-mcp-filesystem/blob/main/src/tools.rs) +- using `tools()` in list tools request : [https://github.com/rust-mcp-stack/rust-mcp-filesystem/blob/main/src/handler.rs](https://github.com/rust-mcp-stack/rust-mcp-filesystem/blob/main/src/handler.rs#L67) +- using `try_from` in call tool_request: [https://github.com/rust-mcp-stack/rust-mcp-filesystem/blob/main/src/handler.rs](https://github.com/rust-mcp-stack/rust-mcp-filesystem/blob/main/src/handler.rs#L100) + + + +### mcp_elicit +The `mcp_elicit` macro generates implementations for the annotated struct to facilitate data elicitation. It enables struct to generate `ElicitRequestedSchema` and also parsing a map of field names to `ElicitResultContentValue` values back into the struct, supporting both required and optional fields. The generated implementation includes: + +- A `message()` method returning the elicitation message as a string. +- A `requested_schema()` method returning an `ElicitRequestedSchema` based on the struct’s JSON schema. +- A `from_content_map()` method to convert a map of `ElicitResultContentValue` values into a struct instance. + +### Attributes + +- `message` - An optional string (or `concat!(...)` expression) to prompt the user or system for input. Defaults to an empty string if not provided. + +Usage example: +```rust +// A struct that could be used to send elicit request and get the input from the user +#[mcp_elicit(message = "Please enter your info")] +#[derive(JsonSchema)] +pub struct UserInfo { + #[json_schema( + title = "Name", + description = "The user's full name", + min_length = 5, + max_length = 100 + )] + pub name: String, + /// Is user a student? + #[json_schema(title = "Is student?", default = true)] + pub is_student: Option, + + /// User's favorite color + pub favorate_color: Colors, +} + +// send a Elicit Request , ask for UserInfo data and convert the result back to a valid UserInfo instance +let result: ElicitResult = server + .elicit_input(UserInfo::message(), UserInfo::requested_schema()) + .await?; + +// Create a UserInfo instance using data provided by the user on the client side +let user_info = UserInfo::from_content_map(result.content)?; + +``` + +💻 For mre info please see : +- https://github.com/rust-mcp-stack/rust-mcp-sdk/tree/main/crates/rust-mcp-macros + ## Getting Started If you are looking for a step-by-step tutorial on how to get started with `rust-mcp-sdk` , please see : [Getting Started MCP Server](https://github.com/rust-mcp-stack/rust-mcp-sdk/tree/main/doc/getting-started-mcp-server.md) @@ -509,6 +618,7 @@ The `rust-mcp-sdk` crate provides several features that can be enabled or disabl - `stdio`: Enables support for the `standard input/output (stdio)` transport. - `tls-no-provider`: Enables TLS without a crypto provider. This is useful if you are already using a different crypto provider than the aws-lc default. + #### MCP Protocol Versions with Corresponding Features - `2025_06_18` : Activates MCP Protocol version 2025-06-18 (enabled by default) @@ -621,6 +731,10 @@ Below is a list of projects that utilize the `rust-mcp-sdk`, showcasing their na + + + + ## Contributing We welcome everyone who wishes to contribute! Please refer to the [contributing](CONTRIBUTING.md) guidelines for more details. diff --git a/crates/rust-mcp-macros/README.md b/crates/rust-mcp-macros/README.md index 92da2c3..fc463cd 100644 --- a/crates/rust-mcp-macros/README.md +++ b/crates/rust-mcp-macros/README.md @@ -1,5 +1,8 @@ # rust-mcp-macros. + +## mcp_tool Macro + A procedural macro, part of the [rust-mcp-sdk](https://github.com/rust-mcp-stack/rust-mcp-sdk) ecosystem, to generate `rust_mcp_schema::Tool` instance from a struct. The `mcp_tool` macro generates an implementation for the annotated struct that includes: @@ -80,11 +83,7 @@ fn main() { ``` ---- - Check out [rust-mcp-sdk](https://github.com/rust-mcp-stack/rust-mcp-sdk) , a high-performance, asynchronous toolkit for building MCP servers and clients. Focus on your app's logic while [rust-mcp-sdk](https://github.com/rust-mcp-stack/rust-mcp-sdk) takes care of the rest! - ---- **Note**: The following attributes are available only in version `2025_03_26` and later of the MCP Schema, and their values will be used in the [annotations](https://github.com/rust-mcp-stack/rust-mcp-schema/blob/main/src/generated_schema/2025_03_26/mcp_schema.rs#L5557) attribute of the *[Tool struct](https://github.com/rust-mcp-stack/rust-mcp-schema/blob/main/src/generated_schema/2025_03_26/mcp_schema.rs#L5554-L5566). @@ -93,3 +92,106 @@ fn main() { - `idempotent_hint` - `open_world_hint` - `read_only_hint` + + + + + +## mcp_elicit Macro + +The `mcp_elicit` macro generates implementations for the annotated struct to facilitate data elicitation. It enables struct to generate `ElicitRequestedSchema` and also parsing a map of field names to `ElicitResultContentValue` values back into the struct, supporting both required and optional fields. The generated implementation includes: + +- A `message()` method returning the elicitation message as a string. +- A `requested_schema()` method returning an `ElicitRequestedSchema` based on the struct’s JSON schema. +- A `from_content_map()` method to convert a map of `ElicitResultContentValue` values into a struct instance. + +### Attributes + +- `message` - An optional string (or `concat!(...)` expression) to prompt the user or system for input. Defaults to an empty string if not provided. + +### Supported Field Types + +- `String`: Maps to `ElicitResultContentValue::String`. +- `bool`: Maps to `ElicitResultContentValue::Boolean`. +- `i32`: Maps to `ElicitResultContentValue::Integer` (with bounds checking). +- `i64`: Maps to `ElicitResultContentValue::Integer`. +- `enum` Only simple enums are supported. The enum must implement the FromStr trait. +- `Option`: Supported for any of the above types, mapping to `None` if the field is missing. + + +### Usage Example + +```rust +use rust_mcp_sdk::macros::{mcp_elicit, JsonSchema}; +use rust_mcp_sdk::schema::RpcError; +use std::str::FromStr; + +// Simple enum with FromStr trait implemented +#[derive(JsonSchema, Debug)] +pub enum Colors { + #[json_schema(title = "Green Color")] + Green, + #[json_schema(title = "Red Color")] + Red, +} +impl FromStr for Colors { + type Err = RpcError; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "green" => Ok(Colors::Green), + "red" => Ok(Colors::Red), + _ => Err(RpcError::parse_error().with_message("Invalid color".to_string())), + } + } +} + +// A struct that could be used to send elicit request and get the input from the user +#[mcp_elicit(message = "Please enter your info")] +#[derive(JsonSchema)] +pub struct UserInfo { + #[json_schema( + title = "Name", + description = "The user's full name", + min_length = 5, + max_length = 100 + )] + pub name: String, + + /// Email address of the user + #[json_schema(title = "Email", format = "email")] + pub email: Option, + + /// The user's age in years + #[json_schema(title = "Age", minimum = 15, maximum = 125)] + pub age: i32, + + /// Is user a student? + #[json_schema(title = "Is student?", default = true)] + pub is_student: Option, + + /// User's favorite color + pub favorate_color: Colors, +} + + // .... + // ....... + // ........... + + // send a Elicit Request , ask for UserInfo data and convert the result back to a valid UserInfo instance + + let result: ElicitResult = server + .elicit_input(UserInfo::message(), UserInfo::requested_schema()) + .await?; + + // Create a UserInfo instance using data provided by the user on the client side + let user_info = UserInfo::from_content_map(result.content)?; + + +``` + +--- + + Check out [rust-mcp-sdk](https://github.com/rust-mcp-stack/rust-mcp-sdk), a high-performance, asynchronous toolkit for building MCP servers and clients. Focus on your app's logic while [rust-mcp-sdk](https://github.com/rust-mcp-stack/rust-mcp-sdk) takes care of the rest! + +--- diff --git a/crates/rust-mcp-macros/src/lib.rs b/crates/rust-mcp-macros/src/lib.rs index 35d6e55..473792c 100644 --- a/crates/rust-mcp-macros/src/lib.rs +++ b/crates/rust-mcp-macros/src/lib.rs @@ -6,7 +6,7 @@ use proc_macro::TokenStream; use quote::quote; use syn::{ parse::Parse, parse_macro_input, punctuated::Punctuated, Data, DeriveInput, Error, Expr, - ExprLit, Fields, Lit, Meta, Token, + ExprLit, Fields, GenericArgument, Lit, Meta, PathArguments, Token, Type, }; use utils::{is_option, renamed_field, type_to_json_schema}; @@ -45,6 +45,8 @@ struct McpToolMacroAttributes { use syn::parse::ParseStream; +use crate::utils::{generate_enum_parse, is_enum}; + struct ExprList { exprs: Punctuated, } @@ -246,6 +248,66 @@ impl Parse for McpToolMacroAttributes { } } +struct McpElicitationAttributes { + message: Option, +} + +impl Parse for McpElicitationAttributes { + fn parse(attributes: syn::parse::ParseStream) -> syn::Result { + let mut instance = Self { message: None }; + let meta_list: Punctuated = Punctuated::parse_terminated(attributes)?; + for meta in meta_list { + if let Meta::NameValue(meta_name_value) = meta { + let ident = meta_name_value.path.get_ident().unwrap(); + let ident_str = ident.to_string(); + if ident_str.as_str() == "message" { + let value = match &meta_name_value.value { + Expr::Lit(ExprLit { + lit: Lit::Str(lit_str), + .. + }) => lit_str.value(), + Expr::Macro(expr_macro) => { + let mac = &expr_macro.mac; + if mac.path.is_ident("concat") { + let args: ExprList = syn::parse2(mac.tokens.clone())?; + let mut result = String::new(); + for expr in args.exprs { + if let Expr::Lit(ExprLit { + lit: Lit::Str(lit_str), + .. + }) = expr + { + result.push_str(&lit_str.value()); + } else { + return Err(Error::new_spanned( + expr, + "Only string literals are allowed inside concat!()", + )); + } + } + result + } else { + return Err(Error::new_spanned( + expr_macro, + "Only concat!(...) is supported here", + )); + } + } + _ => { + return Err(Error::new_spanned( + &meta_name_value.value, + "Expected a string literal or concat!(...)", + )); + } + }; + instance.message = Some(value) + } + } + } + Ok(instance) + } +} + /// A procedural macro attribute to generate rust_mcp_schema::Tool related utility methods for a struct. /// /// The `mcp_tool` macro generates an implementation for the annotated struct that includes: @@ -387,7 +449,7 @@ pub fn mcp_tool(attributes: TokenStream, input: TokenStream) -> TokenStream { let output = quote! { impl #input_ident { - /// Returns the name of the tool as a string. + /// Returns the name of the tool as a String. pub fn tool_name() -> String { #tool_name.to_string() } @@ -404,7 +466,7 @@ pub fn mcp_tool(attributes: TokenStream, input: TokenStream) -> TokenStream { .iter() .filter_map(|item| item.as_str().map(String::from)) .collect(), - None => Vec::new(), // Default to an empty vector if "required" is missing or not an array + None => Vec::new(), }; let properties: Option< @@ -440,6 +502,303 @@ pub fn mcp_tool(attributes: TokenStream, input: TokenStream) -> TokenStream { TokenStream::from(output) } +#[proc_macro_attribute] +pub fn mcp_elicit(attributes: TokenStream, input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + let input_ident = &input.ident; + + // Conditionally select the path + let base_crate = if cfg!(feature = "sdk") { + quote! { rust_mcp_sdk::schema } + } else { + quote! { rust_mcp_schema } + }; + + let macro_attributes = parse_macro_input!(attributes as McpElicitationAttributes); + let message = macro_attributes.message.unwrap_or_default(); + + // Generate field assignments for from_content_map() + let field_assignments = match &input.data { + Data::Struct(data) => match &data.fields { + Fields::Named(fields) => { + let assignments = fields.named.iter().map(|field| { + let field_attrs = &field.attrs; + let field_ident = &field.ident; + let renamed_field = renamed_field(field_attrs); + let field_name = renamed_field.unwrap_or_else(|| field_ident.as_ref().unwrap().to_string()); + let field_type = &field.ty; + + let type_check = if is_option(field_type) { + // Extract inner type for Option + let inner_type = match field_type { + Type::Path(type_path) => { + let segment = type_path.path.segments.last().unwrap(); + if segment.ident == "Option" { + match &segment.arguments { + PathArguments::AngleBracketed(args) => { + match args.args.first().unwrap() { + GenericArgument::Type(ty) => ty, + _ => panic!("Expected type argument in Option"), + } + } + _ => panic!("Invalid Option type"), + } + } else { + panic!("Expected Option type"); + } + } + _ => panic!("Expected Option type"), + }; + // Determine the match arm based on the inner type at compile time + let (inner_type_ident, match_pattern, conversion) = match inner_type { + Type::Path(type_path) if type_path.path.is_ident("String") => ( + quote! { String }, + quote! { #base_crate::ElicitResultContentValue::String(s) }, + quote! { s.clone() } + ), + Type::Path(type_path) if type_path.path.is_ident("bool") => ( + quote! { bool }, + quote! { #base_crate::ElicitResultContentValue::Boolean(b) }, + quote! { *b } + ), + Type::Path(type_path) if type_path.path.is_ident("i32") => ( + quote! { i32 }, + quote! { #base_crate::ElicitResultContentValue::Integer(i) }, + quote! { + (*i).try_into().map_err(|_| #base_crate::RpcError::parse_error().with_message(format!( + "Invalid number for field '{}': value {} does not fit in i32", + #field_name, *i + )))? + } + ), + Type::Path(type_path) if type_path.path.is_ident("i64") => ( + quote! { i64 }, + quote! { #base_crate::ElicitResultContentValue::Integer(i) }, + quote! { *i } + ), + _ if is_enum(inner_type, &input) => { + let enum_parse = generate_enum_parse(inner_type, &field_name, &base_crate); + ( + quote! { #inner_type }, + quote! { #base_crate::ElicitResultContentValue::String(s) }, + quote! { #enum_parse } + ) + } + _ => panic!("Unsupported inner type for Option field: {}", quote! { #inner_type }), + }; + let inner_type_str = quote! { stringify!(#inner_type_ident) }; + quote! { + let #field_ident: Option<#inner_type_ident> = match content.as_ref().and_then(|map| map.get(#field_name)) { + Some(value) => { + match value { + #match_pattern => Some(#conversion), + _ => { + return Err(#base_crate::RpcError::parse_error().with_message(format!( + "Type mismatch for field '{}': expected {}, found {}", + #field_name, #inner_type_str, + match value { + #base_crate::ElicitResultContentValue::Boolean(_) => "boolean", + #base_crate::ElicitResultContentValue::String(_) => "string", + #base_crate::ElicitResultContentValue::Integer(_) => "integer", + } + ))); + } + } + } + None => None, + }; + } + } else { + // Determine the match arm based on the field type at compile time + let (field_type_ident, match_pattern, conversion) = match field_type { + Type::Path(type_path) if type_path.path.is_ident("String") => ( + quote! { String }, + quote! { #base_crate::ElicitResultContentValue::String(s) }, + quote! { s.clone() } + ), + Type::Path(type_path) if type_path.path.is_ident("bool") => ( + quote! { bool }, + quote! { #base_crate::ElicitResultContentValue::Boolean(b) }, + quote! { *b } + ), + Type::Path(type_path) if type_path.path.is_ident("i32") => ( + quote! { i32 }, + quote! { #base_crate::ElicitResultContentValue::Integer(i) }, + quote! { + (*i).try_into().map_err(|_| #base_crate::RpcError::parse_error().with_message(format!( + "Invalid number for field '{}': value {} does not fit in i32", + #field_name, *i + )))? + } + ), + Type::Path(type_path) if type_path.path.is_ident("i64") => ( + quote! { i64 }, + quote! { #base_crate::ElicitResultContentValue::Integer(i) }, + quote! { *i } + ), + _ if is_enum(field_type, &input) => { + let enum_parse = generate_enum_parse(field_type, &field_name, &base_crate); + ( + quote! { #field_type }, + quote! { #base_crate::ElicitResultContentValue::String(s) }, + quote! { #enum_parse } + ) + } + _ => panic!("Unsupported field type: {}", quote! { #field_type }), + }; + let type_str = quote! { stringify!(#field_type_ident) }; + quote! { + let #field_ident: #field_type_ident = match content.as_ref().and_then(|map| map.get(#field_name)) { + Some(value) => { + match value { + #match_pattern => #conversion, + _ => { + return Err(#base_crate::RpcError::parse_error().with_message(format!( + "Type mismatch for field '{}': expected {}, found {}", + #field_name, #type_str, + match value { + #base_crate::ElicitResultContentValue::Boolean(_) => "boolean", + #base_crate::ElicitResultContentValue::String(_) => "string", + #base_crate::ElicitResultContentValue::Integer(_) => "integer", + } + ))); + } + } + } + None => { + return Err(#base_crate::RpcError::parse_error().with_message(format!( + "Missing required field: {}", + #field_name + ))); + } + }; + } + }; + + type_check + }); + + let field_idents = fields.named.iter().map(|field| &field.ident); + + quote! { + #(#assignments)* + + Ok(Self { + #(#field_idents,)* + }) + } + } + _ => panic!("mcp_elicit macro only supports structs with named fields"), + }, + _ => panic!("mcp_elicit macro only supports structs"), + }; + + let output = quote! { + impl #input_ident { + + /// Returns the elicitation message defined in the `#[mcp_elicit(message = "...")]` attribute. + /// + /// This message is used to prompt the user or system for input when eliciting data for the struct. + /// If no message is provided in the attribute, an empty string is returned. + /// + /// # Returns + /// A `String` containing the elicitation message. + pub fn message()->String{ + #message.to_string() + } + + /// This method returns a `ElicitRequestedSchema` by retrieves the + /// struct's JSON schema (via the `JsonSchema` derive) and converting int into + /// a `ElicitRequestedSchema`. It extracts the `required` fields and + /// `properties` from the schema, mapping them to a `HashMap` of `PrimitiveSchemaDefinition` objects. + /// + /// # Returns + /// An `ElicitRequestedSchema` representing the schema of the struct. + /// + /// # Panics + /// Panics if the schema's properties cannot be converted to `PrimitiveSchemaDefinition` or if the schema + /// is malformed. + pub fn requested_schema() -> #base_crate::ElicitRequestedSchema { + let json_schema = &#input_ident::json_schema(); + + let required: Vec<_> = match json_schema.get("required").and_then(|r| r.as_array()) { + Some(arr) => arr + .iter() + .filter_map(|item| item.as_str().map(String::from)) + .collect(), + None => Vec::new(), + }; + + let properties: Option> = json_schema + .get("properties") + .and_then(|v| v.as_object()) // Safely extract "properties" as an object. + .map(|properties| { + properties + .iter() + .filter_map(|(key, value)| { + serde_json::to_value(value) + .ok() // If serialization fails, return None. + .and_then(|v| { + if let serde_json::Value::Object(obj) = v { + Some(obj) + } else { + None + } + }) + .map(|obj| (key.to_string(), #base_crate::PrimitiveSchemaDefinition::try_from(&obj))) + }) + .collect() + }); + + let properties = properties + .map(|map| { + map.into_iter() + .map(|(k, v)| v.map(|ok_v| (k, ok_v))) // flip Result inside tuple + .collect::, _>>() // collect only if all Ok + }) + .transpose() + .unwrap(); + + let properties = + properties.expect("Was not able to create a ElicitRequestedSchema"); + + let requested_schema = #base_crate::ElicitRequestedSchema::new(properties, required); + requested_schema + } + + /// Converts a map of field names and `ElicitResultContentValue` into an instance of the struct. + /// + /// This method parses the provided content map, matching field names to struct fields and converting + /// `ElicitResultContentValue` variants into the appropriate Rust types (e.g., `String`, `bool`, `i32`, + /// `i64`, or simple enums). It supports both required and optional fields (`Option`). + /// + /// # Parameters + /// - `content`: An optional `HashMap` mapping field names to `ElicitResultContentValue` values. + /// + /// # Returns + /// - `Ok(Self)` if the map is successfully parsed into the struct. + /// - `Err(RpcError)` if: + /// - A required field is missing. + /// - A value’s type does not match the expected field type. + /// - An integer value cannot be converted (e.g., `i64` to `i32` out of bounds). + /// - An enum value is invalid (e.g., string value does not match a enum variant name). + /// + /// # Errors + /// Returns `RpcError` with messages like: + /// - `"Missing required field: {}"` + /// - `"Type mismatch for field '{}': expected {}, found {}"` + /// - `"Invalid number for field '{}': value {} does not fit in i32"` + /// - `"Invalid enum value for field '{}': expected 'Yes' or 'No', found '{}'"`. + pub fn from_content_map(content: ::std::option::Option<::std::collections::HashMap<::std::string::String, #base_crate::ElicitResultContentValue>>) -> Result { + #field_assignments + } + } + #input + }; + + TokenStream::from(output) +} + /// Derives a JSON Schema representation for a struct. /// /// This procedural macro generates a `json_schema()` method for the annotated struct, returning a @@ -473,70 +832,222 @@ pub fn mcp_tool(attributes: TokenStream, input: TokenStream) -> TokenStream { /// # Dependencies /// Relies on `serde_json` for `Map` and `Value` types. /// -#[proc_macro_derive(JsonSchema)] +#[proc_macro_derive(JsonSchema, attributes(json_schema))] pub fn derive_json_schema(input: TokenStream) -> TokenStream { - let input = parse_macro_input!(input as DeriveInput); + let input = syn::parse_macro_input!(input as DeriveInput); let name = &input.ident; - let fields = match &input.data { + let schema_body = match &input.data { Data::Struct(data) => match &data.fields { - Fields::Named(fields) => &fields.named, - _ => panic!("JsonSchema derive macro only supports named fields"), + Fields::Named(fields) => { + let field_entries = fields.named.iter().map(|field| { + let field_attrs = &field.attrs; + let renamed_field = renamed_field(field_attrs); + let field_name = + renamed_field.unwrap_or(field.ident.as_ref().unwrap().to_string()); + let field_type = &field.ty; + + let schema = type_to_json_schema(field_type, field_attrs); + quote! { + properties.insert( + #field_name.to_string(), + serde_json::Value::Object(#schema) + ); + } + }); + + let required_fields = fields.named.iter().filter_map(|field| { + let renamed_field = renamed_field(&field.attrs); + let field_name = + renamed_field.unwrap_or(field.ident.as_ref().unwrap().to_string()); + + let field_type = &field.ty; + if !is_option(field_type) { + Some(quote! { + required.push(#field_name.to_string()); + }) + } else { + None + } + }); + + quote! { + let mut schema = serde_json::Map::new(); + let mut properties = serde_json::Map::new(); + let mut required = Vec::new(); + + #(#field_entries)* + + #(#required_fields)* + + schema.insert("type".to_string(), serde_json::Value::String("object".to_string())); + schema.insert("properties".to_string(), serde_json::Value::Object(properties)); + if !required.is_empty() { + schema.insert("required".to_string(), serde_json::Value::Array( + required.into_iter().map(serde_json::Value::String).collect() + )); + } + + schema + } + } + _ => panic!("JsonSchema derive macro only supports named fields for structs"), }, - _ => panic!("JsonSchema derive macro only supports structs"), - }; + Data::Enum(data) => { + let variant_schemas = data.variants.iter().map(|variant| { + let variant_attrs = &variant.attrs; + let variant_name = variant.ident.to_string(); + let renamed_variant = renamed_field(variant_attrs).unwrap_or(variant_name.clone()); - let field_entries = fields.iter().map(|field| { - let field_attrs = &field.attrs; - let renamed_field = renamed_field(field_attrs); - let field_name = renamed_field.unwrap_or(field.ident.as_ref().unwrap().to_string()); - let field_type = &field.ty; + // Parse variant-level json_schema attributes + let mut title: Option = None; + let mut description: Option = None; + for attr in variant_attrs { + if attr.path().is_ident("json_schema") { + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("title") { + title = Some(meta.value()?.parse::()?.value()); + } else if meta.path.is_ident("description") { + description = Some(meta.value()?.parse::()?.value()); + } + Ok(()) + }); + } + } - let schema = type_to_json_schema(field_type, field_attrs); - quote! { - properties.insert( - #field_name.to_string(), - serde_json::Value::Object(#schema) - ); - } - }); + let title_quote = title.as_ref().map(|t| { + quote! { map.insert("title".to_string(), serde_json::Value::String(#t.to_string())); } + }); + let description_quote = description.as_ref().map(|desc| { + quote! { map.insert("description".to_string(), serde_json::Value::String(#desc.to_string())); } + }); - let required_fields = fields.iter().filter_map(|field| { - let renamed_field = renamed_field(&field.attrs); - let field_name = renamed_field.unwrap_or(field.ident.as_ref().unwrap().to_string()); + match &variant.fields { + Fields::Unit => { + // Unit variant: use "enum" with the variant name + quote! { + { + let mut map = serde_json::Map::new(); + map.insert("enum".to_string(), serde_json::Value::Array(vec![ + serde_json::Value::String(#renamed_variant.to_string()) + ])); + #title_quote + #description_quote + serde_json::Value::Object(map) + } + } + } + Fields::Unnamed(fields) => { + // Newtype or tuple variant + if fields.unnamed.len() == 1 { + // Newtype variant: use the inner type's schema + let field = &fields.unnamed[0]; + let field_type = &field.ty; + let field_attrs = &field.attrs; + let schema = type_to_json_schema(field_type, field_attrs); + quote! { + { + let mut map = #schema; + #title_quote + #description_quote + serde_json::Value::Object(map) + } + } + } else { + // Tuple variant: array with items + let field_schemas = fields.unnamed.iter().map(|field| { + let field_type = &field.ty; + let field_attrs = &field.attrs; + let schema = type_to_json_schema(field_type, field_attrs); + quote! { serde_json::Value::Object(#schema) } + }); + quote! { + { + let mut map = serde_json::Map::new(); + map.insert("type".to_string(), serde_json::Value::String("array".to_string())); + map.insert("items".to_string(), serde_json::Value::Array(vec![#(#field_schemas),*])); + map.insert("additionalItems".to_string(), serde_json::Value::Bool(false)); + #title_quote + #description_quote + serde_json::Value::Object(map) + } + } + } + } + Fields::Named(fields) => { + // Struct variant: object with properties and required fields + let field_entries = fields.named.iter().map(|field| { + let field_attrs = &field.attrs; + let renamed_field = renamed_field(field_attrs); + let field_name = renamed_field.unwrap_or(field.ident.as_ref().unwrap().to_string()); + let field_type = &field.ty; - let field_type = &field.ty; - if !is_option(field_type) { - Some(quote! { - required.push(#field_name.to_string()); - }) - } else { - None - } - }); + let schema = type_to_json_schema(field_type, field_attrs); + quote! { + properties.insert( + #field_name.to_string(), + serde_json::Value::Object(#schema) + ); + } + }); - let expanded = quote! { - impl #name { - pub fn json_schema() -> serde_json::Map { - let mut schema = serde_json::Map::new(); - let mut properties = serde_json::Map::new(); - let mut required = Vec::new(); + let required_fields = fields.named.iter().filter_map(|field| { + let renamed_field = renamed_field(&field.attrs); + let field_name = renamed_field.unwrap_or(field.ident.as_ref().unwrap().to_string()); + + let field_type = &field.ty; + if !is_option(field_type) { + Some(quote! { + required.push(#field_name.to_string()); + }) + } else { + None + } + }); - #(#field_entries)* + quote! { + { + let mut map = serde_json::Map::new(); + let mut properties = serde_json::Map::new(); + let mut required = Vec::new(); - #(#required_fields)* + #(#field_entries)* - schema.insert("type".to_string(), serde_json::Value::String("object".to_string())); - schema.insert("properties".to_string(), serde_json::Value::Object(properties)); - if !required.is_empty() { - schema.insert("required".to_string(), serde_json::Value::Array( - required.into_iter().map(serde_json::Value::String).collect() - )); + #(#required_fields)* + + map.insert("type".to_string(), serde_json::Value::String("object".to_string())); + map.insert("properties".to_string(), serde_json::Value::Object(properties)); + if !required.is_empty() { + map.insert("required".to_string(), serde_json::Value::Array( + required.into_iter().map(serde_json::Value::String).collect() + )); + } + #title_quote + #description_quote + serde_json::Value::Object(map) + } + } + } } + }); + quote! { + let mut schema = serde_json::Map::new(); + schema.insert("oneOf".to_string(), serde_json::Value::Array(vec![ + #(#variant_schemas),* + ])); schema } } + _ => panic!("JsonSchema derive macro only supports structs and enums"), + }; + + let expanded = quote! { + impl #name { + pub fn json_schema() -> serde_json::Map { + #schema_body + } + } }; TokenStream::from(expanded) } diff --git a/crates/rust-mcp-macros/src/utils.rs b/crates/rust-mcp-macros/src/utils.rs index 0d4bbed..71d3de3 100644 --- a/crates/rust-mcp-macros/src/utils.rs +++ b/crates/rust-mcp-macros/src/utils.rs @@ -1,5 +1,8 @@ use quote::quote; -use syn::{punctuated::Punctuated, token, Attribute, Path, PathArguments, Type}; +use syn::{ + punctuated::Punctuated, token, Attribute, DeriveInput, Lit, LitInt, LitStr, Path, + PathArguments, Type, +}; // Check if a type is an Option pub fn is_option(ty: &Type) -> bool { @@ -13,8 +16,8 @@ pub fn is_option(ty: &Type) -> bool { false } -// Check if a type is a Vec #[allow(unused)] +// Check if a type is a Vec pub fn is_vec(ty: &Type) -> bool { if let Type::Path(type_path) = ty { if type_path.path.segments.len() == 1 { @@ -26,8 +29,8 @@ pub fn is_vec(ty: &Type) -> bool { false } -// Extract the inner type from Vec or Option #[allow(unused)] +// Extract the inner type from Vec or Option pub fn inner_type(ty: &Type) -> Option<&Type> { if let Type::Path(type_path) = ty { if type_path.path.segments.len() == 1 { @@ -46,12 +49,11 @@ pub fn inner_type(ty: &Type) -> Option<&Type> { None } -fn doc_comment(attrs: &[Attribute]) -> Option { +pub fn doc_comment(attrs: &[Attribute]) -> Option { let mut docs = Vec::new(); for attr in attrs { if attr.path().is_ident("doc") { if let syn::Meta::NameValue(meta) = &attr.meta { - // Match value as Expr::Lit, then extract Lit::Str if let syn::Expr::Lit(expr_lit) = &meta.value { if let syn::Lit::Str(lit_str) = &expr_lit.lit { docs.push(lit_str.value().trim().to_string()); @@ -82,16 +84,143 @@ pub fn might_be_struct(ty: &Type) -> bool { false } +// Helper to check if a type is an enum +pub fn is_enum(ty: &Type, _input: &DeriveInput) -> bool { + if let Type::Path(type_path) = ty { + // Check for #[mcp_elicit(enum)] attribute on the type + // Since we can't access the enum's definition directly, we rely on the attribute + // This assumes the enum is marked with #[mcp_elicit(enum)] in its definition + // Alternatively, we could pass a list of known enums, but attribute-based is simpler + type_path + .path + .segments + .last() + .map(|s| { + // For now, we'll assume any type could be an enum if it has the attribute + // In a real-world scenario, we'd need to resolve the type's definition + // For simplicity, we check if the type name is plausible (not String, bool, i32, i64) + let ident = s.ident.to_string(); + !["String", "bool", "i32", "i64"].contains(&ident.as_str()) + }) + .unwrap_or(false) + } else { + false + } +} + +// Helper to generate enum parsing code +pub fn generate_enum_parse( + field_type: &Type, + field_name: &str, + base_crate: &proc_macro2::TokenStream, +) -> proc_macro2::TokenStream { + let type_ident = match field_type { + Type::Path(type_path) => type_path.path.segments.last().unwrap().ident.clone(), + _ => panic!("Expected path type for enum"), + }; + // Since we can't access the enum's variants directly in this context, + // we'll assume the enum has unit variants and expect strings matching their names + // In a real-world scenario, you'd parse the enum's Data::Enum to get variant names + // For now, we'll generate a generic parse assuming variant names are provided as strings + quote! { + { + // Attempt to parse the string using a match + // Since we don't have the variants, we rely on the enum implementing FromStr + match s.as_str() { + // We can't dynamically list variants, so we use FromStr + // If FromStr is not implemented, this will fail at compile time + s => s.parse().map_err(|_| #base_crate::RpcError::parse_error().with_message(format!( + "Invalid enum value for field '{}': cannot parse '{}' into {}", + #field_name, s, stringify!(#type_ident) + )))? + } + } + } +} + pub fn type_to_json_schema(ty: &Type, attrs: &[Attribute]) -> proc_macro2::TokenStream { - let number_types = [ - "i8", "i16", "i32", "i64", "i128", "u8", "u16", "u32", "u64", "u128", "f32", "f64", + let integer_types = [ + "i8", "i16", "i32", "i64", "i128", "u8", "u16", "u32", "u64", "u128", ]; - let doc_comment = doc_comment(attrs); - let description = doc_comment.as_ref().map(|desc| { + let float_types = ["f32", "f64"]; + + // Parse custom json_schema attributes + let mut title: Option = None; + let mut format: Option = None; + let mut min_length: Option = None; + let mut max_length: Option = None; + let mut minimum: Option = None; + let mut maximum: Option = None; + let mut default: Option = None; + let mut attr_description: Option = None; + + for attr in attrs { + if attr.path().is_ident("json_schema") { + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("title") { + title = Some(meta.value()?.parse::()?.value()); + } else if meta.path.is_ident("description") { + attr_description = Some(meta.value()?.parse::()?.value()); + } else if meta.path.is_ident("format") { + format = Some(meta.value()?.parse::()?.value()); + } else if meta.path.is_ident("min_length") { + min_length = Some(meta.value()?.parse::()?.base10_parse::()?); + } else if meta.path.is_ident("max_length") { + max_length = Some(meta.value()?.parse::()?.base10_parse::()?); + } else if meta.path.is_ident("minimum") { + minimum = Some(meta.value()?.parse::()?.base10_parse::()?); + } else if meta.path.is_ident("maximum") { + maximum = Some(meta.value()?.parse::()?.base10_parse::()?); + } else if meta.path.is_ident("default") { + let lit = meta.value()?.parse::()?; + default = Some(match lit { + Lit::Str(lit_str) => { + let value = lit_str.value(); + quote! { serde_json::Value::String(#value.to_string()) } + } + Lit::Int(lit_int) => { + let value = lit_int.base10_parse::()?; + assert!( + (i64::MIN..=i64::MAX).contains(&value), + "Default value {value} out of range for i64" + ); + quote! { serde_json::Value::Number(serde_json::Number::from(#value)) } + } + Lit::Float(lit_float) => { + let value = lit_float.base10_parse::()?; + quote! { serde_json::Value::Number(serde_json::Number::from_f64(#value).expect("Invalid float")) } + } + Lit::Bool(lit_bool) => { + let value = lit_bool.value(); + quote! { serde_json::Value::Bool(#value) } + } + _ => return Err(meta.error("Unsupported default value type")), + }); + } + Ok(()) + }); + } + } + + let description = attr_description.or(doc_comment(attrs)); + let description_quote = description.as_ref().map(|desc| { quote! { map.insert("description".to_string(), serde_json::Value::String(#desc.to_string())); } }); + + let title_quote = title.as_ref().map(|t| { + quote! { + map.insert("title".to_string(), serde_json::Value::String(#t.to_string())); + } + }); + + let default_quote = default.as_ref().map(|d| { + quote! { + map.insert("default".to_string(), #d); + } + }); + match ty { Type::Path(type_path) => { if type_path.path.segments.len() == 1 { @@ -104,15 +233,43 @@ pub fn type_to_json_schema(ty: &Type, attrs: &[Attribute]) -> proc_macro2::Token if args.args.len() == 1 { if let syn::GenericArgument::Type(inner_ty) = &args.args[0] { let inner_schema = type_to_json_schema(inner_ty, attrs); + let format_quote = format.as_ref().map(|f| { + quote! { + map.insert("format".to_string(), serde_json::Value::String(#f.to_string())); + } + }); + let min_quote = min_length.as_ref().map(|min| { + quote! { + map.insert("minLength".to_string(), serde_json::Value::Number(serde_json::Number::from(#min))); + } + }); + let max_quote = max_length.as_ref().map(|max| { + quote! { + map.insert("maxLength".to_string(), serde_json::Value::Number(serde_json::Number::from(#max))); + } + }); + let min_num_quote = minimum.as_ref().map(|min| { + quote! { + map.insert("minimum".to_string(), serde_json::Value::Number(serde_json::Number::from(#min))); + } + }); + let max_num_quote = maximum.as_ref().map(|max| { + quote! { + map.insert("maximum".to_string(), serde_json::Value::Number(serde_json::Number::from(#max))); + } + }); return quote! { { - let mut map = serde_json::Map::new(); - let inner_map = #inner_schema; - for (k, v) in inner_map { - map.insert(k, v); - } + let mut map = #inner_schema; map.insert("nullable".to_string(), serde_json::Value::Bool(true)); - #description + #description_quote + #title_quote + #format_quote + #min_quote + #max_quote + #min_num_quote + #max_num_quote + #default_quote map } }; @@ -126,12 +283,26 @@ pub fn type_to_json_schema(ty: &Type, attrs: &[Attribute]) -> proc_macro2::Token if args.args.len() == 1 { if let syn::GenericArgument::Type(inner_ty) = &args.args[0] { let inner_schema = type_to_json_schema(inner_ty, &[]); + let min_quote = min_length.as_ref().map(|min| { + quote! { + map.insert("minItems".to_string(), serde_json::Value::Number(serde_json::Number::from(#min))); + } + }); + let max_quote = max_length.as_ref().map(|max| { + quote! { + map.insert("maxItems".to_string(), serde_json::Value::Number(serde_json::Number::from(#max))); + } + }); return quote! { { let mut map = serde_json::Map::new(); map.insert("type".to_string(), serde_json::Value::String("array".to_string())); map.insert("items".to_string(), serde_json::Value::Object(#inner_schema)); - #description + #description_quote + #title_quote + #min_quote + #max_quote + #default_quote map } }; @@ -144,36 +315,104 @@ pub fn type_to_json_schema(ty: &Type, attrs: &[Attribute]) -> proc_macro2::Token let path = &type_path.path; return quote! { { - let inner_schema = #path::json_schema(); - inner_schema + let mut map = #path::json_schema(); + #description_quote + #title_quote + #default_quote + map } }; } - // Handle basic types + // Handle String else if ident == "String" { + let format_quote = format.as_ref().map(|f| { + quote! { + map.insert("format".to_string(), serde_json::Value::String(#f.to_string())); + } + }); + let min_quote = min_length.as_ref().map(|min| { + quote! { + map.insert("minLength".to_string(), serde_json::Value::Number(serde_json::Number::from(#min))); + } + }); + let max_quote = max_length.as_ref().map(|max| { + quote! { + map.insert("maxLength".to_string(), serde_json::Value::Number(serde_json::Number::from(#max))); + } + }); return quote! { { let mut map = serde_json::Map::new(); map.insert("type".to_string(), serde_json::Value::String("string".to_string())); - #description + #description_quote + #title_quote + #format_quote + #min_quote + #max_quote + #default_quote map } }; - } else if number_types.iter().any(|t| ident == t) { + } + // Handle integer types + else if integer_types.iter().any(|t| ident == t) { + let min_quote = minimum.as_ref().map(|min| { + quote! { + map.insert("minimum".to_string(), serde_json::Value::Number(serde_json::Number::from(#min))); + } + }); + let max_quote = maximum.as_ref().map(|max| { + quote! { + map.insert("maximum".to_string(), serde_json::Value::Number(serde_json::Number::from(#max))); + } + }); + return quote! { + { + let mut map = serde_json::Map::new(); + map.insert("type".to_string(), serde_json::Value::String("integer".to_string())); + #description_quote + #title_quote + #min_quote + #max_quote + #default_quote + map + } + }; + } + // Handle float types + else if float_types.iter().any(|t| ident == t) { + let min_quote = minimum.as_ref().map(|min| { + quote! { + map.insert("minimum".to_string(), serde_json::Value::Number(serde_json::Number::from(#min))); + } + }); + let max_quote = maximum.as_ref().map(|max| { + quote! { + map.insert("maximum".to_string(), serde_json::Value::Number(serde_json::Number::from(#max))); + } + }); return quote! { { let mut map = serde_json::Map::new(); map.insert("type".to_string(), serde_json::Value::String("number".to_string())); - #description + #description_quote + #title_quote + #min_quote + #max_quote + #default_quote map } }; - } else if ident == "bool" { + } + // Handle bool + else if ident == "bool" { return quote! { { let mut map = serde_json::Map::new(); map.insert("type".to_string(), serde_json::Value::String("boolean".to_string())); - #description + #description_quote + #title_quote + #default_quote map } }; @@ -184,7 +423,9 @@ pub fn type_to_json_schema(ty: &Type, attrs: &[Attribute]) -> proc_macro2::Token { let mut map = serde_json::Map::new(); map.insert("type".to_string(), serde_json::Value::String("unknown".to_string())); - #description + #description_quote + #title_quote + #default_quote map } } @@ -193,7 +434,9 @@ pub fn type_to_json_schema(ty: &Type, attrs: &[Attribute]) -> proc_macro2::Token { let mut map = serde_json::Map::new(); map.insert("type".to_string(), serde_json::Value::String("unknown".to_string())); - #description + #description_quote + #title_quote + #default_quote map } }, @@ -204,7 +447,6 @@ pub fn type_to_json_schema(ty: &Type, attrs: &[Attribute]) -> proc_macro2::Token pub fn has_derive(attrs: &[Attribute], trait_name: &str) -> bool { attrs.iter().any(|attr| { if attr.path().is_ident("derive") { - // Parse the derive arguments as a comma-separated list of paths let parsed = attr.parse_args_with(Punctuated::::parse_terminated); if let Ok(derive_paths) = parsed { let derived = derive_paths.iter().any(|path| path.is_ident(trait_name)); @@ -220,7 +462,6 @@ pub fn renamed_field(attrs: &[Attribute]) -> Option { for attr in attrs { if attr.path().is_ident("serde") { - // Ignore other serde meta items (e.g., skip_serializing_if) let _ = attr.parse_nested_meta(|meta| { if meta.path.is_ident("rename") { if let Ok(lit) = meta.value() { @@ -493,12 +734,12 @@ mod tests { } #[test] - fn test_json_schema_number() { + fn test_json_schema_integer() { let ty: syn::Type = parse_quote!(i32); let tokens = type_to_json_schema(&ty, &[]); let output = render(tokens); assert!(output - .contains("\"type\".to_string(),serde_json::Value::String(\"number\".to_string())")); + .contains("\"type\".to_string(),serde_json::Value::String(\"integer\".to_string())")); } #[test] @@ -527,7 +768,7 @@ mod tests { let output = render(tokens); assert!(output.contains("\"nullable\".to_string(),serde_json::Value::Bool(true)")); assert!(output - .contains("\"type\".to_string(),serde_json::Value::String(\"number\".to_string())")); + .contains("\"type\".to_string(),serde_json::Value::String(\"integer\".to_string())")); } #[test] diff --git a/crates/rust-mcp-macros/tests/common/common.rs b/crates/rust-mcp-macros/tests/common/common.rs index 40c4e3c..d6bae2e 100644 --- a/crates/rust-mcp-macros/tests/common/common.rs +++ b/crates/rust-mcp-macros/tests/common/common.rs @@ -1,4 +1,7 @@ +use std::str::FromStr; + use rust_mcp_macros::JsonSchema; +use rust_mcp_schema::RpcError; #[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug, JsonSchema)] /// Represents a text replacement operation. @@ -26,3 +29,50 @@ pub struct EditFileTool { )] pub dry_run: Option, } + +#[derive(JsonSchema, Debug)] +pub enum Colors { + #[json_schema(title = "Green Color")] + Green, + #[json_schema(title = "Red Color")] + Red, +} + +impl FromStr for Colors { + type Err = RpcError; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "green" => Ok(Colors::Green), + "red" => Ok(Colors::Red), + _ => Err(RpcError::parse_error().with_message("Invalid color".to_string())), + } + } +} + +#[mcp_elicit(message = "Please enter your info")] +#[derive(JsonSchema)] +pub struct UserInfo { + #[json_schema( + title = "Name", + description = "The user's full name", + min_length = 5, + max_length = 100 + )] + pub name: String, + + /// Email address of the user + #[json_schema(title = "Email", format = "email")] + pub email: Option, + + /// The user's age in years + #[json_schema(title = "Age", minimum = 15, maximum = 125)] + pub age: i32, + + /// Is user a student? + #[json_schema(title = "Is student?", default = true)] + pub is_student: Option, + + /// User's favorite color + pub favorate_color: Colors, +} diff --git a/crates/rust-mcp-macros/tests/macro_test.rs b/crates/rust-mcp-macros/tests/macro_test.rs index 3a23c87..4b6c926 100644 --- a/crates/rust-mcp-macros/tests/macro_test.rs +++ b/crates/rust-mcp-macros/tests/macro_test.rs @@ -1,4 +1,16 @@ +#[macro_use] +extern crate rust_mcp_macros; + +use std::collections::HashMap; + use common::EditOperation; +use rust_mcp_schema::{ + BooleanSchema, ElicitRequestedSchema, ElicitResultContentValue, EnumSchema, NumberSchema, + PrimitiveSchemaDefinition, StringSchema, StringSchemaFormat, +}; +use serde_json::json; + +use crate::common::{Colors, UserInfo}; #[path = "common/common.rs"] pub mod common; @@ -31,3 +43,232 @@ fn test_rename() { let properties = schema.get("properties").unwrap().as_object().unwrap(); assert_eq!(properties.len(), 2); } + +#[test] +fn test_attributes() { + #[derive(JsonSchema)] + struct User { + /// This is a fallback description from doc comment. + pub id: i32, + + #[json_schema( + title = "User Name", + description = "The user's full name (overrides doc)", + min_length = 1, + max_length = 100 + )] + pub name: String, + + #[json_schema( + title = "User Email", + format = "email", + min_length = 5, + max_length = 255 + )] + pub email: Option, + + #[json_schema( + title = "Tags", + description = "List of tags", + min_length = 0, + max_length = 10 + )] + pub tags: Vec, + } + + let schema = User::json_schema(); + let expected = json!({ + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "This is a fallback description from doc comment." + }, + "name": { + "type": "string", + "title": "User Name", + "description": "The user's full name (overrides doc)", + "minLength": 1, + "maxLength": 100 + }, + "email": { + "type": "string", + "title": "User Email", + "format": "email", + "minLength": 5, + "maxLength": 255, + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "type": "string", + }, + "title": "Tags", + "description": "List of tags", + "minItems": 0, + "maxItems": 10 + } + }, + "required": ["id", "name", "tags"] + }); + + // Convert expected_value from serde_json::Value to serde_json::Map + let expected: serde_json::Map = + expected.as_object().expect("Expected JSON object").clone(); + + assert_eq!(schema, expected); +} + +#[test] +fn test_elicit_macro() { + assert_eq!(UserInfo::message(), "Please enter your info"); + + let requested_schema: ElicitRequestedSchema = UserInfo::requested_schema(); + assert_eq!( + requested_schema.required, + vec!["name", "age", "favorate_color"] + ); + + assert!(matches!( + requested_schema.properties.get("is_student").unwrap(), + PrimitiveSchemaDefinition::BooleanSchema(BooleanSchema { + default, + description, + title, + .. + }) + if + description.as_ref().unwrap() == "Is user a student?" && + title.as_ref().unwrap() == "Is student?" && + matches!(default, Some(true)) + + )); + + assert!(matches!( + requested_schema.properties.get("favorate_color").unwrap(), + PrimitiveSchemaDefinition::EnumSchema(EnumSchema { + description, + enum_, + enum_names, + title, + .. + }) + if description.as_ref().unwrap() == "User's favorite color" && + title.is_none() && + enum_.len()==2 && enum_.iter().all(|s| ["Green", "Red"].contains(&s.as_str())) && + enum_names.len()==2 && enum_names.iter().all(|s| ["Green Color", "Red Color"].contains(&s.as_str())) + )); + + assert!(matches!( + requested_schema.properties.get("age").unwrap(), + PrimitiveSchemaDefinition::NumberSchema(NumberSchema { + description, + maximum, + minimum, + title, + type_ + }) + if + description.as_ref().unwrap() == "The user's age in years" && + maximum.unwrap() == 125 && minimum.unwrap() == 15 && title.as_ref().unwrap() == "Age" + )); + + assert!(matches!( + requested_schema.properties.get("name").unwrap(), + PrimitiveSchemaDefinition::StringSchema(StringSchema { + description, + format, + max_length, + min_length, + title, + .. + }) + if format.is_none() && + description.as_ref().unwrap() == "The user's full name" && + max_length.unwrap() == 100 && min_length.unwrap() == 5 && title.as_ref().unwrap() == "Name" + )); + + assert!(matches!( + requested_schema.properties.get("email").unwrap(), + PrimitiveSchemaDefinition::StringSchema(StringSchema { + description, + format, + max_length, + min_length, + title, + .. + }) if matches!(format.unwrap(), StringSchemaFormat::Email) && + description.as_ref().unwrap() == "Email address of the user" && + max_length.is_none() && min_length.is_none() && title.as_ref().unwrap() == "Email" + )); + + let json_schema = &UserInfo::json_schema(); + + let required: Vec<_> = match json_schema.get("required").and_then(|r| r.as_array()) { + Some(arr) => arr + .iter() + .filter_map(|item| item.as_str().map(String::from)) + .collect(), + None => Vec::new(), + }; + + let properties: Option> = json_schema + .get("properties") + .and_then(|v| v.as_object()) // Safely extract "properties" as an object. + .map(|properties| { + properties + .iter() + .filter_map(|(key, value)| { + serde_json::to_value(value) + .ok() // If serialization fails, return None. + .and_then(|v| { + if let serde_json::Value::Object(obj) = v { + Some(obj) + } else { + None + } + }) + .map(|obj| (key.to_string(), PrimitiveSchemaDefinition::try_from(&obj))) + }) + .collect() + }); + + let properties = properties + .map(|map| { + map.into_iter() + .map(|(k, v)| v.map(|ok_v| (k, ok_v))) // flip Result inside tuple + .collect::, _>>() // collect only if all Ok + }) + .transpose() + .unwrap(); + + let properties = properties.expect("Was not able to create a ElicitRequestedSchema"); + + ElicitRequestedSchema::new(properties, required); +} + +#[test] +fn test_from_content_map() { + let mut content: ::std::collections::HashMap<::std::string::String, ElicitResultContentValue> = + HashMap::new(); + + content.extend([ + ( + "name".to_string(), + ElicitResultContentValue::String("Ali".to_string()), + ), + ( + "favorate_color".to_string(), + ElicitResultContentValue::String("Green".to_string()), + ), + ("age".to_string(), ElicitResultContentValue::Integer(15)), + ( + "is_student".to_string(), + ElicitResultContentValue::Boolean(false), + ), + ]); + + let u: UserInfo = UserInfo::from_content_map(Some(content)).unwrap(); + assert!(matches!(u.favorate_color, Colors::Green)); +} diff --git a/crates/rust-mcp-sdk/README.md b/crates/rust-mcp-sdk/README.md index 51c3b49..2c70c3e 100644 --- a/crates/rust-mcp-sdk/README.md +++ b/crates/rust-mcp-sdk/README.md @@ -38,8 +38,8 @@ This project supports following transports: - ✅ Batch Messages - ✅ Streaming & non-streaming JSON response - ✅ Streamable HTTP Support for MCP Clients -- ⬜ Resumability -- ⬜ Authentication / Oauth +- ✅ Resumability +- ⬜ Oauth Authentication **⚠️** Project is currently under development and should be used at your own risk. @@ -50,6 +50,7 @@ This project supports following transports: - [MCP Client (stdio)](#mcp-client-stdio) - [MCP Client (Streamable HTTP)](#mcp-client_streamable-http)) - [MCP Client (sse)](#mcp-client-sse) +- [Macros](#macros) - [Getting Started](#getting-started) - [HyperServerOptions](#hyperserveroptions) - [Security Considerations](#security-considerations) @@ -386,6 +387,114 @@ Creating an MCP client using the `rust-mcp-sdk` with the SSE transport is almost 👉 see [examples/simple-mcp-client-sse](https://github.com/rust-mcp-stack/rust-mcp-sdk/tree/main/examples/simple-mcp-client-sse) for a complete working example. +## Macros +[rust-mcp-sdk](https://github.com/rust-mcp-stack/rust-mcp-sdk) includes several helpful macros that simplify common tasks when building MCP servers and clients. For example, they can automatically generate tool specifications and tool schemas right from your structs, or assist with elicitation requests and responses making them completely type safe. + +> To use these macros, ensure the `macros` feature is enabled in your Cargo.toml. + +### mcp_tool +`mcp_tool` is a procedural macro attribute that helps generating rust_mcp_schema::Tool from a struct. + +Usage example: +```rust +#[mcp_tool( + name = "move_file", + title="Move File", + description = concat!("Move or rename files and directories. Can move files between directories ", +"and rename them in a single operation. If the destination exists, the ", +"operation will fail. Works across different directories and can be used ", +"for simple renaming within the same directory. ", +"Both source and destination must be within allowed directories."), + destructive_hint = false, + idempotent_hint = false, + open_world_hint = false, + read_only_hint = false +)] +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug, JsonSchema)] +pub struct MoveFileTool { + /// The source path of the file to move. + pub source: String, + /// The destination path to move the file to. + pub destination: String, +} + +// Now we can call `tool()` method on it to get a Tool instance +let rust_mcp_sdk::schema::Tool = MoveFileTool::tool(); + +``` + +💻 For a real-world example, check out any of the tools available at: https://github.com/rust-mcp-stack/rust-mcp-filesystem/tree/main/src/tools + + +### tool_box +`tool_box` generates an enum from a provided list of tools, making it easier to organize and manage them, especially when your application includes a large number of tools. + +It accepts an array of tools and generates an enum where each tool becomes a variant of the enum. + +Generated enum has a `tools()` function that returns a `Vec` , and a `TryFrom` trait implementation that could be used to convert a ToolRequest into a Tool instance. + +Usage example: +```rust + // Accepts an array of tools and generates an enum named `FileSystemTools`, + // where each tool becomes a variant of the enum. + tool_box!(FileSystemTools, [ReadFileTool, MoveFileTool, SearchFilesTool]); + + // now in the app, we can use the FileSystemTools, like: + let all_tools: Vec = FileSystemTools::tools(); +``` + +💻 To see a real-world example of that please see : +- `tool_box` macro usage: [https://github.com/rust-mcp-stack/rust-mcp-filesystem/blob/main/src/tools.rs](https://github.com/rust-mcp-stack/rust-mcp-filesystem/blob/main/src/tools.rs) +- using `tools()` in list tools request : [https://github.com/rust-mcp-stack/rust-mcp-filesystem/blob/main/src/handler.rs](https://github.com/rust-mcp-stack/rust-mcp-filesystem/blob/main/src/handler.rs#L67) +- using `try_from` in call tool_request: [https://github.com/rust-mcp-stack/rust-mcp-filesystem/blob/main/src/handler.rs](https://github.com/rust-mcp-stack/rust-mcp-filesystem/blob/main/src/handler.rs#L100) + + + +### mcp_elicit +The `mcp_elicit` macro generates implementations for the annotated struct to facilitate data elicitation. It enables struct to generate `ElicitRequestedSchema` and also parsing a map of field names to `ElicitResultContentValue` values back into the struct, supporting both required and optional fields. The generated implementation includes: + +- A `message()` method returning the elicitation message as a string. +- A `requested_schema()` method returning an `ElicitRequestedSchema` based on the struct’s JSON schema. +- A `from_content_map()` method to convert a map of `ElicitResultContentValue` values into a struct instance. + +### Attributes + +- `message` - An optional string (or `concat!(...)` expression) to prompt the user or system for input. Defaults to an empty string if not provided. + +Usage example: +```rust +// A struct that could be used to send elicit request and get the input from the user +#[mcp_elicit(message = "Please enter your info")] +#[derive(JsonSchema)] +pub struct UserInfo { + #[json_schema( + title = "Name", + description = "The user's full name", + min_length = 5, + max_length = 100 + )] + pub name: String, + /// Is user a student? + #[json_schema(title = "Is student?", default = true)] + pub is_student: Option, + + /// User's favorite color + pub favorate_color: Colors, +} + +// send a Elicit Request , ask for UserInfo data and convert the result back to a valid UserInfo instance +let result: ElicitResult = server + .elicit_input(UserInfo::message(), UserInfo::requested_schema()) + .await?; + +// Create a UserInfo instance using data provided by the user on the client side +let user_info = UserInfo::from_content_map(result.content)?; + +``` + +💻 For mre info please see : +- https://github.com/rust-mcp-stack/rust-mcp-sdk/tree/main/crates/rust-mcp-macros + ## Getting Started If you are looking for a step-by-step tutorial on how to get started with `rust-mcp-sdk` , please see : [Getting Started MCP Server](https://github.com/rust-mcp-stack/rust-mcp-sdk/tree/main/doc/getting-started-mcp-server.md) @@ -509,6 +618,7 @@ The `rust-mcp-sdk` crate provides several features that can be enabled or disabl - `stdio`: Enables support for the `standard input/output (stdio)` transport. - `tls-no-provider`: Enables TLS without a crypto provider. This is useful if you are already using a different crypto provider than the aws-lc default. + #### MCP Protocol Versions with Corresponding Features - `2025_06_18` : Activates MCP Protocol version 2025-06-18 (enabled by default) @@ -621,6 +731,10 @@ Below is a list of projects that utilize the `rust-mcp-sdk`, showcasing their na + + + + ## Contributing We welcome everyone who wishes to contribute! Please refer to the [contributing](CONTRIBUTING.md) guidelines for more details. diff --git a/crates/rust-mcp-sdk/src/hyper_servers/app_state.rs b/crates/rust-mcp-sdk/src/hyper_servers/app_state.rs index e7f8793..f96b261 100644 --- a/crates/rust-mcp-sdk/src/hyper_servers/app_state.rs +++ b/crates/rust-mcp-sdk/src/hyper_servers/app_state.rs @@ -3,7 +3,9 @@ use std::{sync::Arc, time::Duration}; use super::session_store::SessionStore; use crate::mcp_traits::mcp_handler::McpServerHandler; use crate::{id_generator::FastIdGenerator, mcp_traits::IdGenerator, schema::InitializeResult}; + use rust_mcp_transport::event_store::EventStore; + use rust_mcp_transport::{SessionId, TransportOptions}; /// Application state struct for the Hyper server diff --git a/crates/rust-mcp-sdk/src/mcp_traits/mcp_server.rs b/crates/rust-mcp-sdk/src/mcp_traits/mcp_server.rs index dc860b6..da087d1 100644 --- a/crates/rust-mcp-sdk/src/mcp_traits/mcp_server.rs +++ b/crates/rust-mcp-sdk/src/mcp_traits/mcp_server.rs @@ -4,9 +4,10 @@ use crate::schema::{ ResultFromClient, ServerMessage, }, CallToolRequest, CreateMessageRequest, CreateMessageRequestParams, CreateMessageResult, - GetPromptRequest, Implementation, InitializeRequestParams, InitializeResult, - ListPromptsRequest, ListResourceTemplatesRequest, ListResourcesRequest, ListRootsRequest, - ListRootsRequestParams, ListRootsResult, ListToolsRequest, LoggingMessageNotification, + ElicitRequest, ElicitRequestParams, ElicitRequestedSchema, ElicitResult, GetPromptRequest, + Implementation, InitializeRequestParams, InitializeResult, ListPromptsRequest, + ListResourceTemplatesRequest, ListResourcesRequest, ListRootsRequest, ListRootsRequestParams, + ListRootsResult, ListToolsRequest, LoggingMessageNotification, LoggingMessageNotificationParams, PingRequest, PromptListChangedNotification, PromptListChangedNotificationParams, ReadResourceRequest, RequestId, ResourceListChangedNotification, ResourceListChangedNotificationParams, @@ -58,6 +59,23 @@ pub trait McpServer: Sync + Send { &self.server_info().capabilities } + /// Sends an elicitation request to the client to prompt user input and returns the received response. + /// + /// The requested_schema argument allows servers to define the structure of the expected response using a restricted subset of JSON Schema. + /// To simplify client user experience, elicitation schemas are limited to flat objects with primitive properties only + async fn elicit_input( + &self, + message: String, + requested_schema: ElicitRequestedSchema, + ) -> SdkResult { + let request: ElicitRequest = ElicitRequest::new(ElicitRequestParams { + message, + requested_schema, + }); + let response = self.request(request.into(), None).await?; + ElicitResult::try_from(response).map_err(|err| err.into()) + } + /// Sends a request to the client and processes the response. /// /// This function sends a `RequestFromServer` message to the client, waits for the response, diff --git a/crates/rust-mcp-sdk/tests/common/common.rs b/crates/rust-mcp-sdk/tests/common/common.rs index 6b78895..d6b45f7 100644 --- a/crates/rust-mcp-sdk/tests/common/common.rs +++ b/crates/rust-mcp-sdk/tests/common/common.rs @@ -404,7 +404,7 @@ pub mod sample_tools { tokio::time::sleep(Duration::from_millis(self.interval)).await; } - let message = format!("so many messages sent"); + let message = "so many messages sent".to_string(); Ok(CallToolResult::text_content(vec![TextContent::from( message, )])) diff --git a/crates/rust-mcp-sdk/tests/test_streamable_http_client.rs b/crates/rust-mcp-sdk/tests/test_streamable_http_client.rs index 1d273e5..ceb778a 100644 --- a/crates/rust-mcp-sdk/tests/test_streamable_http_client.rs +++ b/crates/rust-mcp-sdk/tests/test_streamable_http_client.rs @@ -309,7 +309,7 @@ async fn should_handle_successful_initial_get_connection_for_sse() { // let payload = r#"{"jsonrpc": "2.0", "method": "serverNotification", "params": {}}"#; // let mut body = String::new(); - body.push_str(&"data: Connection established\n\n".to_string()); + body.push_str("data: Connection established\n\n"); let response = ResponseTemplate::new(200) .set_body_raw(body.into_bytes(), "text/event-stream") @@ -428,7 +428,7 @@ async fn should_attempt_initial_get_connection_and_handle_405_gracefully() { // let payload = r#"{"jsonrpc": "2.0", "method": "serverNotification", "params": {}}"#; // let mut body = String::new(); - body.push_str(&"data: Connection established\n\n".to_string()); + body.push_str("data: Connection established\n\n"); let response = ResponseTemplate::new(405) .set_body_raw(body.into_bytes(), "text/event-stream") diff --git a/crates/rust-mcp-sdk/tests/test_streamable_http_server.rs b/crates/rust-mcp-sdk/tests/test_streamable_http_server.rs index af2dce6..79c9f00 100644 --- a/crates/rust-mcp-sdk/tests/test_streamable_http_server.rs +++ b/crates/rust-mcp-sdk/tests/test_streamable_http_server.rs @@ -302,7 +302,7 @@ async fn get_standalone_stream( headers.insert("mcp-session-id", session_id); headers.insert("mcp-protocol-version", "2025-03-26"); - if let Some(last_event_id) = last_event_id.clone() { + if let Some(last_event_id) = last_event_id { headers.insert("last-event-id", last_event_id); } diff --git a/examples/hello-world-mcp-server-stdio/src/tools.rs b/examples/hello-world-mcp-server-stdio/src/tools.rs index 15d6a8b..f6b1edb 100644 --- a/examples/hello-world-mcp-server-stdio/src/tools.rs +++ b/examples/hello-world-mcp-server-stdio/src/tools.rs @@ -1,8 +1,29 @@ use rust_mcp_sdk::schema::{schema_utils::CallToolError, CallToolResult, TextContent}; -use rust_mcp_sdk::{ - macros::{mcp_tool, JsonSchema}, - tool_box, -}; +use rust_mcp_sdk::{macros::mcp_tool, tool_box}; + +use rust_mcp_sdk::macros::JsonSchema; +use rust_mcp_sdk::schema::RpcError; +use std::str::FromStr; + +// Simple enum with FromStr trait implemented +#[derive(JsonSchema, Debug)] +pub enum Colors { + #[json_schema(title = "Green Color")] + Green, + #[json_schema(title = "Red Color")] + Red, +} +impl FromStr for Colors { + type Err = RpcError; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "green" => Ok(Colors::Green), + "red" => Ok(Colors::Red), + _ => Err(RpcError::parse_error().with_message("Invalid color".to_string())), + } + } +} //****************// // SayHelloTool //