From cb0f7fb8c03ea5515a3a1fc912e226445eaf2d9e Mon Sep 17 00:00:00 2001 From: Sri Krishna <7254698+srikrsna@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:54:01 +0530 Subject: [PATCH] Add experimental mcp command to forge --- Cargo.lock | 1232 +++++++++++++++++++++++++++++++++- Cargo.toml | 12 + src/bin/forge/main.rs | 53 +- src/bin/forge/mcp.rs | 388 +++++++++++ src/bin/forge/mcp/request.rs | 288 ++++++++ src/bin/forge/mcp/schema.rs | 218 ++++++ src/bin/forge/mcp/tools.rs | 633 +++++++++++++++++ tests/mcp.rs | 334 +++++++++ 8 files changed, 3127 insertions(+), 31 deletions(-) create mode 100644 src/bin/forge/mcp.rs create mode 100644 src/bin/forge/mcp/request.rs create mode 100644 src/bin/forge/mcp/schema.rs create mode 100644 src/bin/forge/mcp/tools.rs create mode 100644 tests/mcp.rs diff --git a/Cargo.lock b/Cargo.lock index db4f44398..60adba9e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,15 @@ version = "0.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -96,6 +105,93 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bitflags" version = "2.13.2" @@ -151,6 +247,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -160,6 +258,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chacha20" version = "0.10.2" @@ -171,6 +275,18 @@ dependencies = [ "rand_core", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + [[package]] name = "clap" version = "4.6.7" @@ -212,6 +328,15 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "cobs" version = "0.3.0" @@ -227,12 +352,48 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "condtype" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf0a07a401f374238ab8e2f11a104d2851bf9ce711ec69804834de8af45c7af" +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "core_detect" version = "1.0.0" @@ -502,6 +663,18 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.18.0" @@ -572,11 +745,16 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" name = "forge" version = "0.1.0" dependencies = [ + "axum", "clap", "divan", + "http", "rayon", + "reqwest", + "rmcp", "serde", "serde_json", + "tokio", "wasmtime", "wasmtime-wasi", ] @@ -601,6 +779,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.34" @@ -609,6 +793,7 @@ checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", + "futures-executor", "futures-io", "futures-sink", "futures-task", @@ -631,12 +816,34 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "futures-sink" version = "0.3.34" @@ -658,6 +865,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -675,6 +883,19 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -682,9 +903,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "rand_core", + "wasm-bindgen", ] [[package]] @@ -699,6 +922,25 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -722,6 +964,138 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.3.0" @@ -894,40 +1268,116 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] -name = "leb128fmt" -version = "0.1.0" +name = "jni" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] [[package]] -name = "libc" -version = "0.2.190" +name = "jni-macros" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] [[package]] -name = "libm" -version = "0.2.16" +name = "jni-sys" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] [[package]] -name = "linux-raw-sys" -version = "0.12.1" +name = "jni-sys-macros" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] [[package]] -name = "litemap" -version = "0.8.3" +name = "jobserver" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] [[package]] -name = "log" -version = "0.4.34" +name = "js-sys" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" [[package]] name = "mach2" @@ -935,6 +1385,12 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "maybe-owned" version = "0.3.4" @@ -956,6 +1412,12 @@ dependencies = [ "rustix", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "mio" version = "1.2.4" @@ -973,6 +1435,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "object" version = "0.40.0" @@ -997,6 +1468,18 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1009,6 +1492,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + [[package]] name = "postcard" version = "1.1.3" @@ -1062,6 +1551,63 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -1084,7 +1630,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", - "getrandom", + "getrandom 0.4.3", "rand_core", ] @@ -1094,6 +1640,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + [[package]] name = "rayon" version = "1.12.0" @@ -1114,6 +1669,26 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "regalloc2" version = "0.15.2" @@ -1135,6 +1710,89 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmcp" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab5023804cbe0304cbe06197ac16e645a0a0d0ffa2953bf735c15c2fa0213350" +dependencies = [ + "async-trait", + "base64", + "bytes", + "chrono", + "futures", + "http", + "http-body", + "http-body-util", + "indexmap", + "pastey", + "pin-project-lite", + "rand", + "schemars", + "serde", + "serde_json", + "sse-stream", + "thiserror", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "uuid", +] + [[package]] name = "rustc-demangle" version = "0.1.28" @@ -1147,6 +1805,15 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.5" @@ -1170,18 +1837,160 @@ dependencies = [ "rustix", ] +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.6", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -1222,6 +2031,17 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "serde_json" version = "1.0.151" @@ -1252,6 +2072,26 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -1283,6 +2123,19 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sse-stream" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1295,6 +2148,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.119" @@ -1317,6 +2176,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.14.0" @@ -1328,6 +2196,27 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "target-lexicon" version = "0.13.5" @@ -1383,6 +2272,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + [[package]] name = "tokio" version = "1.53.2" @@ -1393,10 +2288,103 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -1428,6 +2416,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.20.1" @@ -1440,6 +2434,12 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -1464,18 +2464,104 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.258.3" @@ -1762,6 +2848,35 @@ dependencies = [ "wasmtime", ] +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -1771,12 +2886,85 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -2031,6 +3219,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + [[package]] name = "zerotrie" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index 184531c7a..4774a1bc0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,10 +7,15 @@ version = "0.1.0" edition = "2024" [dependencies] +axum = { version = "0.8", default-features = false, features = ["http1", "tokio"], optional = true } clap = { version = "4.6.7", features = ["derive"], optional = true } +http = { version = "1", optional = true } rayon = "1.12.0" +reqwest = { version = "0.13.5", optional = true } +rmcp = { version = "3.5.1", default-features = false, features = ["server", "transport-io", "transport-streamable-http-server"], optional = true } serde = { version = "1.0.229", features = ["derive"] } serde_json = { version = "1.0.151", optional = true } +tokio = { version = "1.53.2", features = ["macros", "net", "rt-multi-thread", "signal"], optional = true } wasmtime = { version = "49.0.2", default-features = false, features = ["runtime", "cranelift", "component-model", "parallel-compilation", "std"], optional = true } wasmtime-wasi = { version = "49.0.2", default-features = false, features = ["p2"], optional = true } @@ -29,6 +34,9 @@ default = ["cli"] cli = ["dep:clap", "dep:serde_json"] # `forge::wasm`, which runs plugins compiled to WebAssembly components. wasm = ["dep:wasmtime", "dep:wasmtime-wasi"] +# `forge mcp`, which serves the methods of a document as the tools of an MCP +# server, over HTTP or standard input and output. +mcp = ["cli", "dep:axum", "dep:http", "dep:reqwest", "dep:rmcp", "dep:tokio"] [profile.release] strip = true # Drop symbols and debug info from the binary, which the npm packages ship. @@ -45,6 +53,10 @@ required-features = ["cli"] name = "cli" required-features = ["cli"] +[[test]] +name = "mcp" +required-features = ["cli", "mcp"] + [[test]] name = "wasm" required-features = ["cli", "wasm"] diff --git a/src/bin/forge/main.rs b/src/bin/forge/main.rs index 6781dd8ea..2586e8504 100644 --- a/src/bin/forge/main.rs +++ b/src/bin/forge/main.rs @@ -3,7 +3,11 @@ //! ```text //! forge openapi.json --target ts --out sdk //! forge openapi.json --target wasm://plugin.wasm --out sdk +//! forge mcp openapi.json //! ``` +//! +//! `forge mcp`, which serves the methods of the document as the tools of an +//! MCP server, needs the `mcp` feature. use std::fs; use std::io::{self, Read}; @@ -13,21 +17,38 @@ use std::process::ExitCode; use clap::Parser; use forge::{Err, Forge}; +#[cfg(feature = "mcp")] +mod mcp; mod ts; +// The document, --target and --out are required unless a command is given, +// and are `Option`s so that a command can leave them out. /// Generates an SDK from an OpenAPI document. #[derive(Parser)] -#[command(version)] +#[command(version, args_conflicts_with_subcommands = true, subcommand_negates_reqs = true)] struct Args { + #[cfg(feature = "mcp")] + #[command(subcommand)] + command: Option, /// The OpenAPI JSON document, or - to read it from standard input. - spec: PathBuf, + #[arg(required = true)] + spec: Option, /// The language of the SDK: `ts` for TypeScript, or `wasm://PATH` for /// the plugin compiled to a WebAssembly component at PATH. - #[arg(long, value_parser = Target::parse)] - target: Target, + #[arg(long, value_parser = Target::parse, required = true)] + target: Option, /// The directory to write the SDK to. - #[arg(long)] - out: PathBuf, + #[arg(long, required = true)] + out: Option, +} + +/// What forge can do besides generating an SDK. +#[cfg(feature = "mcp")] +#[derive(clap::Subcommand)] +enum Command { + /// Serves the methods of an OpenAPI document as the tools of an MCP + /// server. + Mcp(mcp::Args), } /// What forge generates an SDK with. @@ -68,20 +89,28 @@ fn main() -> ExitCode { } } -/// Generates the SDK that `args` ask for. +/// Runs the command `args` give, or else generates the SDK they ask for. fn run(args: Args) -> Result<(), Err> { - match args.target { + #[cfg(feature = "mcp")] + if let Some(Command::Mcp(args)) = args.command { + return mcp::run(args, read); + } + // Clap requires these when there is no command. + let (Some(spec), Some(target), Some(out)) = (args.spec, args.target, args.out) else { + unreachable!("clap requires the document, --target and --out"); + }; + match target { Target::Ts => { - let forge = read(&args.spec)?; - forge::write(&args.out, forge::run(&forge, &ts::Generator)?) + let forge = read(&spec)?; + forge::write(&out, forge::run(&forge, &ts::Generator)?) } #[cfg(feature = "wasm")] Target::Wasm(path) => { // Compiling the plugin and reading the document are both slow, // so they run at the same time. - let (plugin, forge) = rayon::join(|| forge::wasm::WasmPlugin::from_file(&path), || read(&args.spec)); + let (plugin, forge) = rayon::join(|| forge::wasm::WasmPlugin::from_file(&path), || read(&spec)); let (plugin, forge) = (plugin?, forge?); - forge::write(&args.out, forge::run(&forge, &plugin)?) + forge::write(&out, forge::run(&forge, &plugin)?) } } } diff --git a/src/bin/forge/mcp.rs b/src/bin/forge/mcp.rs new file mode 100644 index 000000000..402387890 --- /dev/null +++ b/src/bin/forge/mcp.rs @@ -0,0 +1,388 @@ +//! `forge mcp`, which serves the methods of an OpenAPI document as the tools +//! of an [MCP](https://modelcontextprotocol.io) server, and calls the API +//! over HTTP when a client calls one. +//! +//! ```text +//! forge mcp openapi.json +//! forge mcp openapi.json --listen 127.0.0.1:9000 --group dns.records +//! forge mcp openapi.json --transport stdio +//! ``` +//! +//! The server speaks MCP with [rmcp](https://crates.io/crates/rmcp), the +//! official Rust SDK, over Streamable HTTP at `/mcp` or over standard input +//! and output. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use clap::ValueEnum; +use forge::{Err, Forge}; +use rmcp::model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, Implementation, ListToolsResult, + PaginatedRequestParams, ServerCapabilities, ServerConfig, +}; +use rmcp::service::RequestContext; +use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; +use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService}; +use rmcp::{ErrorData, RoleServer, ServerHandler, ServiceExt}; + +mod request; +mod schema; +mod tools; + +use request::{HttpRequest, HttpResponse}; +use tools::{Api, Tool}; + +/// The path the HTTP transport serves MCP at. +const HTTP_PATH: &str = "/mcp"; + +/// Serves the methods of an OpenAPI document as the tools of an MCP server. +/// +/// It calls the API at --base-url, and sends the API_TOKEN environment +/// variable, when it is set, as a bearer token. Over HTTP, a client's own +/// Authorization header is sent instead, when it has one. +#[derive(clap::Args)] +pub struct Args { + /// The OpenAPI JSON document, or - to read it from standard input, which + /// the stdio transport cannot. + spec: PathBuf, + /// How clients connect to the server: `http` serves MCP's Streamable + /// HTTP transport at /mcp on --listen, and `stdio` speaks MCP over + /// standard input and output, for a client that runs forge itself. + #[arg(long, value_enum, default_value_t = Transport::Http)] + transport: Transport, + /// The address the HTTP transport listens on. Port 0 picks a free port. + #[arg(long, default_value = "127.0.0.1:8080")] + listen: String, + /// A host name, or `host:port`, that clients may reach the HTTP + /// transport by, besides localhost, 127.0.0.1 and ::1, which are always + /// allowed. Requests for any other host are refused, to guard against + /// DNS rebinding. Can be given more than once. + #[arg(long = "allow-host", value_name = "HOST")] + allowed_hosts: Vec, + /// The base URL of the API. Defaults to the API_BASE_URL environment + /// variable, or else the first server in the document. + #[arg(long)] + base_url: Option, + /// Serve only the methods in GROUP, such as `dns.records`, and in the + /// groups inside it. Can be given more than once. + #[arg(long = "group", value_name = "GROUP")] + groups: Vec, +} + +/// How clients connect to the server. +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +enum Transport { + /// MCP's Streamable HTTP transport. + Http, + /// Standard input and output. + Stdio, +} + +/// Serves the document of `args`, which `read` reads, until the client +/// disconnects from the stdio transport, or the HTTP transport is stopped +/// with Ctrl-C. +pub fn run(args: Args, read: fn(&Path) -> Result) -> Result<(), Err> { + if args.transport == Transport::Stdio && args.spec == Path::new("-") { + return Err("the stdio transport reads MCP from standard input, so it cannot read the document there".into()); + } + let forge = read(&args.spec)?; + let api = Api::new(&forge, &args.groups).map_err(|err| format!("{}: {err}", args.spec.display()))?; + drop(forge); + if !api.skipped.is_empty() { + // A large document can skip many methods, so only some are named. + const SHOWN: usize = 20; + let mut lines = api.skipped[..api.skipped.len().min(SHOWN)].join("\n "); + if api.skipped.len() > SHOWN { + lines.push_str(&format!("\n and {} more", api.skipped.len() - SHOWN)); + } + eprintln!("forge: {} methods have no tool:\n {lines}", api.skipped.len()); + } + + let base_url = args + .base_url + .clone() + .or_else(|| std::env::var("API_BASE_URL").ok()) + .filter(|url| !url.is_empty()) + .unwrap_or_else(|| api.base_url.clone()); + if base_url.is_empty() { + return Err("the document has no servers: give the base URL of the API with --base-url".into()); + } + let token = std::env::var("API_TOKEN").ok().filter(|token| !token.is_empty()); + let server = Server::new(api, base_url, token)?; + + let runtime = tokio::runtime::Builder::new_multi_thread().enable_all().build()?; + runtime.block_on(async { + match args.transport { + Transport::Http => serve_http(server, &args.listen, &args.allowed_hosts).await, + Transport::Stdio => serve_stdio(server).await, + } + }) +} + +/// Serves MCP over Streamable HTTP at `/mcp` on `listen`, until Ctrl-C. +async fn serve_http(server: Server, listen: &str, allowed_hosts: &[String]) -> Result<(), Err> { + let hosts = ["localhost", "127.0.0.1", "::1"] + .into_iter() + .map(str::to_owned) + .chain(allowed_hosts.iter().cloned()); + let config = StreamableHttpServerConfig::default().with_allowed_hosts(hosts); + let tools = server.tools.tools.len(); + let base_url = server.tools.base_url.clone(); + let service = StreamableHttpService::new( + move || Ok(server.clone()), + Arc::new(LocalSessionManager::default()), + config, + ); + let router = axum::Router::new().nest_service(HTTP_PATH, service); + let listener = tokio::net::TcpListener::bind(listen) + .await + .map_err(|err| format!("cannot listen on {listen}: {err}"))?; + let addr = listener.local_addr()?; + eprintln!("forge: serving {tools} tools for {base_url} at http://{addr}{HTTP_PATH}"); + axum::serve(listener, router) + .with_graceful_shutdown(async { + // Without a handler for Ctrl-C, the server runs until killed. + if tokio::signal::ctrl_c().await.is_err() { + std::future::pending::<()>().await; + } + }) + .await?; + Ok(()) +} + +/// Serves MCP over standard input and output, until the client closes +/// standard input. +async fn serve_stdio(server: Server) -> Result<(), Err> { + eprintln!( + "forge: serving {} tools for {} over stdio", + server.tools.tools.len(), + server.tools.base_url + ); + let service = server.serve(rmcp::transport::stdio()).await?; + service.waiting().await?; + Ok(()) +} + +/// Serves the tools of an [`Api`]. Cloning it is cheap, since the HTTP +/// transport makes one for each session. +#[derive(Clone)] +struct Server { + tools: Arc, + http: reqwest::Client, +} + +/// What every clone of a [`Server`] shares. +struct Tools { + description: String, + base_url: String, + /// The token to send as a bearer token, when the client sends none. + token: Option, + tools: Vec, + /// The index of each tool in `tools`, by name. + by_name: HashMap, +} + +impl Server { + /// Returns the server of `api`, which calls the API at `base_url` and + /// sends `token` as a bearer token. + fn new(api: Api, base_url: String, token: Option) -> Result { + let http = reqwest::Client::builder() + .user_agent(concat!("forge/", env!("CARGO_PKG_VERSION"))) + .build()?; + let by_name = api + .tools + .iter() + .enumerate() + .map(|(i, t)| (t.definition.name.to_string(), i)) + .collect(); + Ok(Server { + tools: Arc::new(Tools { + description: api.description, + base_url, + token, + tools: api.tools, + by_name, + }), + http, + }) + } + + /// Calls `tool` with `args`, sending `authorization` as the + /// `Authorization` header, or else the server's token. + async fn call( + &self, + tool: &Tool, + args: serde_json::Map, + authorization: Option<&str>, + ) -> CallToolResult { + let mut request = match HttpRequest::new(&tool.endpoint, &self.tools.base_url, args) { + Ok(request) => request, + Err(err) => return CallToolResult::error(vec![ContentBlock::text(err)]), + }; + if let Some(authorization) = authorization { + request.authorize(authorization); + } else if let Some(token) = &self.tools.token { + request.bearer(token); + } + result(send(&self.http, &request).await) + } +} + +impl ServerHandler for Server { + fn get_info(&self) -> ServerConfig { + let mut config = ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(Implementation::new("forge", env!("CARGO_PKG_VERSION"))); + if !self.tools.description.is_empty() { + config = config.with_instructions(self.tools.description.clone()); + } + config + } + + async fn list_tools( + &self, + _: Option, + _: RequestContext, + ) -> Result { + let definitions = self.tools.tools.iter().map(|t| t.definition.clone()).collect(); + Ok(ListToolsResult::with_all_items(definitions)) + } + + fn get_tool(&self, name: &str) -> Option { + let &i = self.tools.by_name.get(name)?; + Some(self.tools.tools[i].definition.clone()) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + let Some(&i) = self.tools.by_name.get(request.name.as_ref()) else { + return Err(ErrorData::invalid_params( + format!("unknown tool {:?}", request.name), + None, + )); + }; + // Over HTTP, the client's request is in the context. + let authorization = context + .extensions + .get::() + .and_then(|parts| parts.headers.get(http::header::AUTHORIZATION)) + .and_then(|value| value.to_str().ok()); + let args = request.arguments.unwrap_or_default(); + Ok(self.call(&self.tools.tools[i], args, authorization).await.into()) + } +} + +/// Sends `request` and returns its response. +async fn send(http: &reqwest::Client, request: &HttpRequest) -> Result { + let method = reqwest::Method::from_bytes(request.method.as_bytes()).map_err(|err| err.to_string())?; + let mut builder = http.request(method, request.url.as_str()); + for (name, value) in &request.headers { + builder = builder.header(name, value); + } + if let Some(body) = &request.body { + builder = builder.body(body.clone()); + } + let response = builder.send().await.map_err(|err| chain(&err))?; + let status = response.status().as_u16(); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let body = response.bytes().await.map_err(|err| chain(&err))?.to_vec(); + Ok(HttpResponse { + status, + content_type, + body, + }) +} + +/// Returns the result of a tool, given the response to its request, or +/// why there is none. The result is an error when the request failed or the +/// response has an error status, so the client sees why. +fn result(response: Result) -> CallToolResult { + let response = match response { + Ok(response) => response, + Err(err) => return CallToolResult::error(vec![ContentBlock::text(format!("the request failed: {err}"))]), + }; + let text = match String::from_utf8(response.body) { + Ok(text) => text, + Err(err) => format!( + "a response of {} bytes of {}", + err.as_bytes().len(), + response.content_type.as_deref().unwrap_or("binary data"), + ), + }; + if (200..300).contains(&response.status) { + CallToolResult::success(vec![ContentBlock::text(text)]) + } else { + CallToolResult::error(vec![ContentBlock::text(format!("HTTP {}\n\n{text}", response.status))]) + } +} + +/// Returns `err` and the errors that caused it, separated by colons, since +/// reqwest's errors leave out their causes, such as a refused connection. +fn chain(err: &dyn std::error::Error) -> String { + let mut message = err.to_string(); + let mut source = err.source(); + while let Some(err) = source { + message.push_str(": "); + message.push_str(&err.to_string()); + source = err.source(); + } + message +} + +#[cfg(test)] +mod tests { + use serde_json::{Value, json}; + + use super::{HttpResponse, result}; + + fn text(result: &rmcp::model::CallToolResult) -> Value { + serde_json::to_value(result).unwrap() + } + + #[test] + fn results() { + let ok = result(Ok(HttpResponse { + status: 200, + content_type: Some("application/json".to_owned()), + body: br#"{"id":1}"#.to_vec(), + })); + assert_eq!(text(&ok)["content"], json!([{"type": "text", "text": r#"{"id":1}"#}])); + assert_eq!(ok.is_error, Some(false)); + + let not_found = result(Ok(HttpResponse { + status: 404, + content_type: None, + body: b"no such item".to_vec(), + })); + assert_eq!( + text(¬_found)["content"], + json!([{"type": "text", "text": "HTTP 404\n\nno such item"}]) + ); + assert_eq!(not_found.is_error, Some(true)); + + let binary = result(Ok(HttpResponse { + status: 200, + content_type: Some("application/pdf".to_owned()), + body: vec![0xff, 0xfe], + })); + assert_eq!( + text(&binary)["content"][0]["text"], + "a response of 2 bytes of application/pdf" + ); + + let failed = result(Err("connection refused".to_owned())); + assert_eq!( + text(&failed)["content"][0]["text"], + "the request failed: connection refused" + ); + assert_eq!(failed.is_error, Some(true)); + } +} diff --git a/src/bin/forge/mcp/request.rs b/src/bin/forge/mcp/request.rs new file mode 100644 index 000000000..06427db53 --- /dev/null +++ b/src/bin/forge/mcp/request.rs @@ -0,0 +1,288 @@ +//! The HTTP requests that call tools. + +use std::fmt::Write; + +use serde_json::{Map, Value}; + +use super::tools::{Body, Endpoint, Location}; + +/// An HTTP request to send to call a tool. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct HttpRequest { + /// The method, such as `GET`. + pub method: String, + /// The absolute URL, with its query. + pub url: String, + /// The headers, by name, in the order to send them. + pub headers: Vec<(String, String)>, + /// The JSON body, if there is one. + pub body: Option, +} + +impl HttpRequest { + /// Sends `token` as a bearer token, unless the request already has an + /// `Authorization` header, from an argument of the tool. + pub(super) fn bearer(&mut self, token: &str) { + self.authorize(&format!("Bearer {token}")); + } + + /// Sends `authorization` as the `Authorization` header, unless the + /// request already has one, from an argument of the tool. + pub(super) fn authorize(&mut self, authorization: &str) { + if !self.header("authorization") { + self.headers + .push(("Authorization".to_owned(), authorization.to_owned())); + } + } + + /// Reports whether the request has the header `name`, ignoring case. + fn header(&self, name: &str) -> bool { + self.headers.iter().any(|(n, _)| n.eq_ignore_ascii_case(name)) + } + + /// Returns the request that calls `endpoint` with `args`, the + /// arguments of its tool, against the API at `base_url`, or why `args` + /// cannot call it. + pub(super) fn new( + endpoint: &Endpoint, + base_url: &str, + mut args: Map, + ) -> Result { + let mut path = endpoint.path.clone(); + let mut query = String::new(); + let mut headers = Vec::new(); + for param in &endpoint.params { + let name = param.name.as_str(); + let value = match args.remove(name) { + None | Some(Value::Null) if param.location == Location::Path => { + return Err(format!("missing argument `{name}`")); + } + None | Some(Value::Null) => continue, + Some(value) => value, + }; + match param.location { + Location::Path => { + let value = + scalar(&value).ok_or_else(|| format!("`{name}` must be a string, number or boolean"))?; + path = path.replace(&format!("{{{name}}}"), &encode(&value)); + } + Location::Query { exploded } => { + for (key, value) in pairs(name, value, exploded)? { + let sep = if query.is_empty() { '?' } else { '&' }; + write!(query, "{sep}{}={}", encode(&key), encode(&value)).unwrap(); + } + } + Location::Header => { + let value = match &value { + Value::Array(values) => values + .iter() + .map(scalar) + .collect::>>() + .map(|v| v.join(",")), + value => scalar(value), + }; + let value = value.ok_or_else(|| format!("`{name}` must not be an object"))?; + if value.contains(['\r', '\n']) { + return Err(format!("`{name}` must not contain a line break")); + } + headers.push((name.to_owned(), value)); + } + } + } + + let body = match endpoint.body { + Body::None => None, + Body::Flat { required } if args.is_empty() && !required => None, + Body::Flat { .. } => Some(Value::Object(args)), + Body::Field => args.remove("body").filter(|body| !body.is_null()), + }; + let body = body.map(|body| { + headers.push(("Content-Type".to_owned(), "application/json".to_owned())); + body.to_string() + }); + Ok(HttpRequest { + method: endpoint.method.clone(), + url: format!("{}{path}{query}", base_url.trim_end_matches('/')), + headers, + body, + }) + } +} + +/// The response to an [`HttpRequest`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct HttpResponse { + /// The status, such as 200. + pub status: u16, + /// The `Content-Type` header, if there is one. + pub content_type: Option, + pub body: Vec, +} + +/// Returns the pairs that send `value` in the query as the parameter +/// `name`. +fn pairs(name: &str, value: Value, exploded: bool) -> Result, String> { + let scalar = |value: &Value| scalar(value).ok_or_else(|| format!("`{name}` must not nest arrays or objects")); + Ok(match value { + Value::Array(values) if exploded => values + .iter() + .map(|v| Ok((name.to_owned(), scalar(v)?))) + .collect::>()?, + Value::Array(values) => { + let values = values.iter().map(scalar).collect::, _>>()?; + vec![(name.to_owned(), values.join(","))] + } + Value::Object(entries) if exploded => entries + .iter() + .map(|(k, v)| Ok((k.clone(), scalar(v)?))) + .collect::>()?, + Value::Object(entries) => { + let mut parts = Vec::new(); + for (k, v) in &entries { + parts.push(k.clone()); + parts.push(scalar(v)?); + } + vec![(name.to_owned(), parts.join(","))] + } + value => vec![(name.to_owned(), scalar(&value)?)], + }) +} + +/// Returns `value` as it is written in a path, query or header, or `None` +/// if it is null, an array or an object. +fn scalar(value: &Value) -> Option { + match value { + Value::String(s) => Some(s.clone()), + Value::Number(n) => Some(n.to_string()), + Value::Bool(b) => Some(b.to_string()), + Value::Null | Value::Array(_) | Value::Object(_) => None, + } +} + +/// Returns `s` percent-encoded, leaving only unreserved characters as they +/// are, so it is safe in a path segment and in a query. +fn encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for byte in s.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + out.push(char::from(byte)); + } else { + write!(out, "%{byte:02X}").unwrap(); + } + } + out +} + +#[cfg(test)] +mod tests { + use serde_json::{Map, Value, json}; + + use super::HttpRequest; + use crate::mcp::tools::{Body, Endpoint, Location, Param}; + + /// Returns an endpoint that sends `params` and `body` with + /// `POST /a/{id}`. + fn endpoint(params: &[(&str, Location)], body: Body) -> Endpoint { + Endpoint { + method: "POST".to_owned(), + path: "/a/{id}".to_owned(), + params: params + .iter() + .map(|&(name, location)| Param { + name: name.to_owned(), + location, + }) + .collect(), + body, + } + } + + fn args(value: Value) -> Map { + let Value::Object(args) = value else { + panic!("not an object") + }; + args + } + + #[test] + fn parameters() { + let endpoint = endpoint( + &[ + ("id", Location::Path), + ("ids", Location::Query { exploded: true }), + ("tags", Location::Query { exploded: false }), + ("filter", Location::Query { exploded: true }), + ("q", Location::Query { exploded: true }), + ("X-Trace", Location::Header), + ], + Body::Flat { required: false }, + ); + let request = HttpRequest::new( + &endpoint, + "https://api.example.com/v1/", + args(json!({ + "id": "a/b c", "ids": [1, 2], "tags": ["x", "y"], "filter": {"k": "v"}, + "q": null, "X-Trace": "t", "name": "n" + })), + ) + .unwrap(); + assert_eq!( + request, + HttpRequest { + method: "POST".to_owned(), + url: "https://api.example.com/v1/a/a%2Fb%20c?ids=1&ids=2&tags=x%2Cy&k=v".to_owned(), + headers: vec![ + ("X-Trace".to_owned(), "t".to_owned()), + ("Content-Type".to_owned(), "application/json".to_owned()), + ], + body: Some(r#"{"name":"n"}"#.to_owned()), + } + ); + } + + #[test] + fn missing_path_parameter() { + let endpoint = endpoint(&[("id", Location::Path)], Body::None); + let err = HttpRequest::new(&endpoint, "https://x", Map::new()).unwrap_err(); + assert_eq!(err, "missing argument `id`"); + } + + #[test] + fn flat_bodies() { + let path = [("id", Location::Path)]; + let optional = endpoint(&path, Body::Flat { required: false }); + let request = HttpRequest::new(&optional, "https://x", args(json!({"id": 1}))).unwrap(); + assert_eq!((request.url.as_str(), request.body), ("https://x/a/1", None)); + let required = endpoint(&path, Body::Flat { required: true }); + let request = HttpRequest::new(&required, "https://x", args(json!({"id": 1}))).unwrap(); + assert_eq!(request.body.as_deref(), Some("{}")); + } + + #[test] + fn field_body() { + let endpoint = endpoint(&[("id", Location::Path)], Body::Field); + let request = + HttpRequest::new(&endpoint, "https://x", args(json!({"id": 1, "body": [1], "other": 2}))).unwrap(); + assert_eq!(request.body.as_deref(), Some("[1]")); + } + + #[test] + fn bad_header() { + let endpoint = endpoint(&[("id", Location::Path), ("X-A", Location::Header)], Body::None); + let err = HttpRequest::new(&endpoint, "https://x", args(json!({"id": 1, "X-A": "a\r\nB: b"}))).unwrap_err(); + assert!(err.contains("line break"), "{err}"); + } + + #[test] + fn authorization() { + let mut request = HttpRequest { + method: "GET".to_owned(), + url: "https://x".to_owned(), + headers: Vec::new(), + body: None, + }; + request.bearer("t"); + request.bearer("u"); + assert_eq!(request.headers, [("Authorization".to_owned(), "Bearer t".to_owned())]); + } +} diff --git a/src/bin/forge/mcp/schema.rs b/src/bin/forge/mcp/schema.rs new file mode 100644 index 000000000..a9e764c0d --- /dev/null +++ b/src/bin/forge/mcp/schema.rs @@ -0,0 +1,218 @@ +//! JSON Schemas of the arguments of tools. +//! +//! A tool's input schema is an object with a property for each argument. +//! Models are in `$defs` and referenced with `$ref`, so recursive models +//! terminate. Read-only fields are left out, since the schema describes +//! only what a client sends. + +use std::collections::BTreeMap; + +use forge::{EnumValue, Field, Json, Model, Object, Scalar, Type}; +use serde_json::{Map, Value, json}; + +/// Builds the schemas of the arguments of one tool, collecting the models +/// they use. +#[derive(Default)] +pub(super) struct Schemas<'f> { + /// The models referenced so far, by schema name, with their schemas. + /// A model is in the map before its schema is built, so a model that + /// refers to itself is built once. + defs: BTreeMap<&'f str, Option>, +} + +impl<'f> Schemas<'f> { + /// Returns the schema of an object with `properties`, which must be + /// present when listed in `required`, and that is also each schema in + /// `all_of`. The models every schema so far uses are in its `$defs`. + pub(super) fn object( + self, + properties: Map, + required: Vec, + all_of: Vec, + ) -> Map { + let mut schema = Map::new(); + schema.insert("type".to_owned(), json!("object")); + schema.insert("properties".to_owned(), Value::Object(properties)); + if !required.is_empty() { + schema.insert("required".to_owned(), json!(required)); + } + if !all_of.is_empty() { + schema.insert("allOf".to_owned(), Value::Array(all_of)); + } + if !self.defs.is_empty() { + let defs = self + .defs + .into_iter() + .map(|(name, schema)| (name.to_owned(), schema.unwrap_or_else(|| json!({})))) + .collect(); + schema.insert("$defs".to_owned(), Value::Object(defs)); + } + schema + } + + /// Returns the schema of a property for `field`: the schema of its + /// type, with its description and default. + pub(super) fn field(&mut self, field: Field<'f>) -> Value { + let mut schema = self.ty(field.ty()); + if let Value::Object(map) = &mut schema { + if !field.description().is_empty() { + map.insert("description".to_owned(), json!(field.description())); + } + if let Some(default) = field.default() { + map.insert("default".to_owned(), value(default)); + } + if field.is_deprecated() { + map.insert("deprecated".to_owned(), json!(true)); + } + } + schema + } + + /// Returns the schema of `t`. + pub(super) fn ty(&mut self, t: Type<'f>) -> Value { + match t { + Type::Scalar(scalar) => scalar_schema(scalar), + Type::Enum(e) => { + let values: Vec = e + .values() + .map(|v| match v { + EnumValue::String(s) => json!(s), + EnumValue::Number(n) => number(n), + EnumValue::Boolean(b) => json!(b), + }) + .collect(); + json!({ "enum": values }) + } + Type::Array(array) => { + let mut schema = Map::new(); + schema.insert("type".to_owned(), json!("array")); + schema.insert("items".to_owned(), self.ty(array.elem())); + if let Some(min) = array.min_items() { + schema.insert("minItems".to_owned(), json!(min)); + } + if let Some(max) = array.max_items() { + schema.insert("maxItems".to_owned(), json!(max)); + } + Value::Object(schema) + } + Type::Object(object) => self.object_schema(object), + Type::Union(union) => { + let members: Vec = union.members().map(|m| self.ty(m)).collect(); + json!({ "anyOf": members }) + } + Type::Intersection(intersection) => { + let members: Vec = intersection.members().map(|m| self.ty(m)).collect(); + json!({ "allOf": members }) + } + Type::Model(model) => self.model(model), + } + } + + /// Returns a reference to the schema of `model`, building it the first + /// time. + fn model(&mut self, model: Model<'f>) -> Value { + let name = model.schema_name(); + if !self.defs.contains_key(name) { + self.defs.insert(name, None); + let mut schema = self.ty(model.ty()); + if let Value::Object(map) = &mut schema + && !model.description().is_empty() + && !map.contains_key("description") + { + map.insert("description".to_owned(), json!(model.description())); + } + self.defs.insert(name, Some(schema)); + } + json!({ "$ref": format!("#/$defs/{}", pointer(name)) }) + } + + /// Returns the schema of `object`. It allows keys other than its + /// fields, since a flat body's schema is one member of an `allOf` with + /// the parameters, which the body's object would otherwise reject. + fn object_schema(&mut self, object: Object<'f>) -> Value { + let mut properties = Map::new(); + let mut required = Vec::new(); + for field in object.fields().filter(|f| !f.is_read_only()) { + properties.insert(field.wire_name().to_owned(), self.field(field)); + if field.is_required() { + required.push(field.wire_name()); + } + } + let mut schema = Map::new(); + schema.insert("type".to_owned(), json!("object")); + if properties.is_empty() { + if let Some(extra) = object.extra() + && !matches!(extra, Type::Scalar(Scalar::Unknown)) + { + schema.insert("additionalProperties".to_owned(), self.ty(extra)); + } + } else { + schema.insert("properties".to_owned(), Value::Object(properties)); + } + if !required.is_empty() { + schema.insert("required".to_owned(), json!(required)); + } + Value::Object(schema) + } +} + +/// Returns the schema of `scalar`. +fn scalar_schema(scalar: Scalar) -> Value { + match scalar { + Scalar::Unknown => json!({}), + Scalar::Null => json!({ "type": "null" }), + Scalar::Boolean => json!({ "type": "boolean" }), + Scalar::String | Scalar::Binary => json!({ "type": "string" }), + Scalar::Uuid => json!({ "type": "string", "format": "uuid" }), + Scalar::Number(n) if n.is_integer() => json!({ "type": "integer" }), + Scalar::Number(_) => json!({ "type": "number" }), + Scalar::DateTime => json!({ "type": "string", "format": "date-time" }), + Scalar::Date => json!({ "type": "string", "format": "date" }), + Scalar::Bytes => json!({ "type": "string", "contentEncoding": "base64" }), + } +} + +/// Returns `json` as a JSON value. +fn value(json: &Json) -> Value { + match json { + Json::Null => Value::Null, + Json::Bool(b) => json!(b), + Json::Number(n) => number(*n), + Json::String(s) => json!(s), + Json::Array(values) => Value::Array(values.iter().map(value).collect()), + Json::Object(entries) => Value::Object(entries.iter().map(|(k, v)| (k.clone(), value(v))).collect()), + } +} + +/// Returns `n` as a JSON number, written without a fraction when it is an +/// integer, as `1` rather than `1.0`. +fn number(n: f64) -> Value { + if n.fract() == 0.0 && n.abs() < 2f64.powi(53) { + return json!(n as i64); + } + serde_json::Number::from_f64(n).map_or(Value::Null, Value::Number) +} + +/// Returns `name` escaped as a token of a JSON Pointer. +fn pointer(name: &str) -> String { + name.replace('~', "~0").replace('/', "~1") +} + +#[cfg(test)] +mod tests { + use super::{number, pointer}; + use serde_json::json; + + #[test] + fn numbers() { + assert_eq!(number(1.0), json!(1)); + assert_eq!(number(-3.0), json!(-3)); + assert_eq!(number(1.5), json!(1.5)); + } + + #[test] + fn pointers() { + assert_eq!(pointer("Tree/$defs/Node"), "Tree~1$defs~1Node"); + assert_eq!(pointer("a~b"), "a~0b"); + } +} diff --git a/src/bin/forge/mcp/tools.rs b/src/bin/forge/mcp/tools.rs new file mode 100644 index 000000000..4f59c0940 --- /dev/null +++ b/src/bin/forge/mcp/tools.rs @@ -0,0 +1,633 @@ +//! The tools of a Forge document: one for each method. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use forge::{Err, Field, Forge, HttpMethod, Method, Model, Name, Type}; +use rmcp::model::ToolAnnotations; +use serde_json::{Map, Value}; + +use super::schema::Schemas; + +/// The tools of a document, and what the server tells a client about it. +pub(super) struct Api { + /// A description of the API, which the server gives a client as its + /// instructions. Empty when the document has none. + pub description: String, + /// The base URL of the API, from the first server in the document, or + /// empty when it has none. + pub base_url: String, + /// Every tool, sorted by group and then by name. + pub tools: Vec, + /// A line for each method of the selected groups that has no tool, + /// naming it and saying why, such as `files.upload: its request body is + /// not JSON`. + pub skipped: Vec, +} + +/// A method of the API, exposed as a tool. +#[derive(Clone, Debug)] +pub(super) struct Tool { + /// What clients see: the name of the tool, unique within the API, such + /// as `accounts_members_list`, its title, description, the JSON Schema + /// of its arguments, and hints of what calling it does. + pub definition: rmcp::model::Tool, + /// How calling the tool calls the API. + pub endpoint: Endpoint, +} + +/// The operation a [`Tool`] calls, and where its arguments are sent. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct Endpoint { + /// The HTTP method the operation is sent with, such as `GET`. + pub method: String, + /// The path template of the operation, such as `/accounts/{account_id}`. + pub path: String, + /// The arguments that are sent in the path, query or headers. + pub params: Vec, + /// Where the JSON request body comes from. + pub body: Body, +} + +/// An argument of a [`Tool`] that is sent in the path, query or headers. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct Param { + /// The name of the argument, and of the parameter on the wire. + pub name: String, + pub location: Location, +} + +/// Where a [`Param`] is sent. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Location { + /// In the path, in place of `{name}`. + Path, + /// In the query. An exploded array is sent as a pair for each element, + /// and an exploded object as a pair for each property; otherwise the + /// value is sent as one pair, with its parts separated by commas. + Query { exploded: bool }, + /// In the header named `name`, with an array's elements separated by + /// commas. + Header, +} + +/// Where the JSON request body of a [`Tool`] comes from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Body { + /// The operation has no JSON body. + None, + /// The body is the arguments that are not parameters. When there are + /// none, `{}` is sent if the body is required, and nothing otherwise. + Flat { required: bool }, + /// The body is the argument `body`. + Field, +} + +impl Api { + /// Returns a tool for each method of `forge`, or only for those in + /// `groups` and the groups inside them when `groups` is not empty. + /// + /// A group is written as the names of the groups from the top level + /// down, separated by dots, such as `dns.records`, and names match when + /// they are equal as [`Name`]s. A group that matches nothing is an + /// error. + /// + /// A tool is named after its method's groups and its own name, in + /// snake_case, joined by underscores, such as `dns_records_create`. Two + /// methods with the same tool name are an error. Hidden methods have no + /// tool, nor do methods whose request body is not JSON, or with two + /// arguments of the same name: see [`Api::skipped`]. + pub fn new(forge: &Forge, groups: &[String]) -> Result { + let filters = groups + .iter() + .map(|group| { + let path: Vec = group.split('.').map(|name| Name::new(name).snake()).collect(); + if path.iter().any(String::is_empty) { + return Err(format!("{group:?} is not a group: write one as names separated by dots").into()); + } + Ok(path) + }) + .collect::, Err>>()?; + let mut matched = vec![false; filters.len()]; + + let mut tools = Vec::new(); + let mut skipped = Vec::new(); + let mut names: HashMap = HashMap::new(); + for group in forge.groups() { + let path: Vec = group.path().map(Name::snake).collect(); + if !filters.is_empty() { + let mut selected = false; + for (filter, matched) in filters.iter().zip(&mut matched) { + if path.starts_with(filter) { + *matched = true; + selected = true; + } + } + if !selected { + continue; + } + } + for m in group.methods() { + let id = format!("{}.{}", path.join("."), m.name().snake()); + let tool = match tool(m, &path) { + Ok(tool) => tool, + Err(reason) => { + skipped.push(format!("{id}: {reason}")); + continue; + } + }; + let name = tool.definition.name.to_string(); + if let Some(other) = names.insert(name.clone(), id.clone()) { + return Err(format!("{other} and {id} are both the tool {name}").into()); + } + tools.push(tool); + } + } + if let Some(i) = matched.iter().position(|&matched| !matched) { + return Err(format!("there is no group {:?}", groups[i]).into()); + } + + Ok(Api { + description: forge.description.trim().to_owned(), + base_url: forge.base_url.clone(), + tools, + skipped, + }) + } +} + +/// Returns the tool of `m`, whose groups' names are `path`, or why it has +/// none. +fn tool(m: Method<'_>, path: &[String]) -> Result { + if m.is_hidden() { + return Err("it is hidden".to_owned()); + } + if let Some(body) = m.body() + && body.json().is_none() + && body.contents().len() > 0 + { + return Err("its request body is not JSON".to_owned()); + } + let request = Request::new(m); + let mut names = HashSet::new(); + let body_name = matches!(request.body, Some(BodyKind::Field { .. })).then_some("body"); + let arguments = request + .params + .iter() + .map(|(field, _)| field.wire_name()) + .chain(body_name); + for name in arguments { + if !names.insert(name) { + return Err(format!("two of its arguments are named {name:?}")); + } + } + + let mut words = path.to_vec(); + words.push(m.name().snake()); + let title = m.summary().trim(); + let description = description(m); + let mut annotations = annotations(m); + let mut definition = rmcp::model::Tool::new_with_raw( + words.join("_"), + (!description.is_empty()).then(|| description.into()), + Arc::new(request.schema()), + ); + if !title.is_empty() { + annotations.title = Some(title.to_owned()); + definition = definition.with_title(title); + } + Ok(Tool { + definition: definition.with_annotations(annotations), + endpoint: Endpoint { + method: m.http_method().as_str().to_owned(), + path: m.path().to_owned(), + params: request + .params + .iter() + .map(|&(field, location)| Param { + name: field.wire_name().to_owned(), + location, + }) + .collect(), + body: match request.body { + None => Body::None, + Some(BodyKind::Flat { required, .. }) => Body::Flat { required }, + Some(BodyKind::Field { .. }) => Body::Field, + }, + }, + }) +} + +/// Returns the description of the tool of `m`: its summary and description, +/// then whether it is deprecated, and the sentence to confirm it with. +fn description(m: Method<'_>) -> String { + let mut paragraphs: Vec<&str> = Vec::new(); + let summary = m.summary().trim(); + let description = m.description().trim(); + if !summary.is_empty() { + paragraphs.push(summary); + } + if !description.is_empty() && description != summary { + paragraphs.push(description); + } + if m.is_deprecated() { + paragraphs.push("Deprecated."); + } + if let Some(confirmation) = m.confirmation().map(str::trim) + && !confirmation.is_empty() + { + paragraphs.push(confirmation); + } + paragraphs.join("\n\n") +} + +/// The arguments of a tool: the path, query and header parameters of its +/// method, each named after its wire name, and its JSON body. +struct Request<'f> { + /// The parameters: path, then query, then headers. + params: Vec<(Field<'f>, Location)>, + body: Option>, +} + +/// Where the body of a method is in the arguments of its tool. +enum BodyKind<'f> { + /// The body's fields are arguments, next to the parameters. + Flat { ty: Type<'f>, required: bool }, + /// The body is the argument `body`. + Field { + ty: Type<'f>, + required: bool, + description: &'f str, + }, +} + +impl<'f> Request<'f> { + /// Returns the arguments of the tool of `m`. The body is flat when + /// [`Method::request`] puts its fields next to the parameters. + fn new(m: Method<'f>) -> Request<'f> { + let request = m.request(); + let object = match request { + Some(Type::Object(object)) => Some(object), + Some(Type::Intersection(intersection)) => match intersection.members().next() { + Some(Type::Object(object)) => Some(object), + _ => None, + }, + _ => None, + }; + let mut params: Vec<(Field<'f>, Location)> = object + .into_iter() + .flat_map(|object| object.fields()) + .filter_map(|field| { + let location = match m.location(field)? { + forge::Location::Path => Location::Path, + forge::Location::Query { exploded } => Location::Query { exploded }, + }; + Some((field, location)) + }) + .collect(); + let has_params = !params.is_empty(); + params.extend( + m.headers() + .into_iter() + .flat_map(|headers| headers.fields()) + .map(|field| (field, Location::Header)), + ); + let body = m.body().and_then(|body| { + let ty = body.json()?.ty(); + let required = body.is_required(); + let flat = if has_params { + matches!(request, Some(Type::Intersection(_))) + } else { + object_like(ty, &mut HashSet::new()) + }; + Some(if flat { + BodyKind::Flat { ty, required } + } else { + BodyKind::Field { + ty, + required, + description: body.description(), + } + }) + }); + Request { params, body } + } + + /// Returns the JSON Schema of the arguments. + fn schema(&self) -> Map { + let mut schemas = Schemas::default(); + let mut properties = Map::new(); + let mut required = Vec::new(); + for (field, _) in &self.params { + properties.insert(field.wire_name().to_owned(), schemas.field(*field)); + if field.is_required() { + required.push(field.wire_name().to_owned()); + } + } + let mut all_of = Vec::new(); + match self.body { + None => {} + Some(BodyKind::Flat { ty, .. }) => all_of.push(schemas.ty(ty)), + Some(BodyKind::Field { + ty, + required: body_required, + description, + }) => { + let mut schema = schemas.ty(ty); + if let Value::Object(map) = &mut schema + && !description.is_empty() + { + map.insert("description".to_owned(), description.into()); + } + properties.insert("body".to_owned(), schema); + if body_required { + required.push("body".to_owned()); + } + } + } + schemas.object(properties, required, all_of) + } +} + +/// Reports whether `t` is an object with fields, or a model, union or +/// intersection of them, whose fields can sit next to the parameters. +fn object_like<'f>(t: Type<'f>, seen: &mut HashSet>) -> bool { + match t { + Type::Object(object) => object.fields().len() > 0, + Type::Model(m) => seen.insert(m) && object_like(m.ty(), seen), + Type::Union(union) => union.members().all(|m| object_like(m, seen)), + Type::Intersection(intersection) => intersection.members().all(|m| object_like(m, seen)), + _ => false, + } +} + +/// Returns the hints of what calling `m` does, from its HTTP method. A +/// method that needs confirmation is destructive. Every tool calls an API +/// outside the server, so each is open-world. +fn annotations(m: Method<'_>) -> ToolAnnotations { + let confirm = m.confirmation().is_some(); + let (read_only, destructive, idempotent) = match m.http_method() { + HttpMethod::Get | HttpMethod::Head | HttpMethod::Options | HttpMethod::Query => (!confirm, confirm, true), + HttpMethod::Put | HttpMethod::Delete => (false, true, true), + HttpMethod::Post | HttpMethod::Patch | HttpMethod::Trace | HttpMethod::Connect | HttpMethod::Other(_) => { + (false, confirm, false) + } + }; + ToolAnnotations::new() + .read_only(read_only) + .destructive(destructive) + .idempotent(idempotent) + .open_world(true) +} + +#[cfg(test)] +mod tests { + use forge::{Err, Forge}; + use serde_json::{Value, json}; + + use super::{Api, Body, Endpoint, Location, Param, Tool}; + + /// Returns the tools of `spec`, in `groups`, or why it has none. + fn try_build(spec: &str, groups: &[&str]) -> Result { + let forge: Forge = serde_json::from_str(spec)?; + let groups: Vec = groups.iter().map(|&g| g.to_owned()).collect(); + Api::new(&forge, &groups) + } + + /// Returns the tools of `spec`, in `groups`. + fn build(spec: &str, groups: &[&str]) -> Api { + try_build(spec, groups).unwrap() + } + + /// Returns the only tool of `spec`. + fn only(spec: &str) -> Tool { + let api = build(spec, &[]); + assert_eq!(api.tools.len(), 1, "{:?}", api.skipped); + api.tools.into_iter().next().unwrap() + } + + /// Returns the definition of `tool`, as clients see it. + fn definition(tool: &Tool) -> Value { + serde_json::to_value(&tool.definition).unwrap() + } + + /// Returns the names of the tools of `api`. + fn names(api: &Api) -> Vec<&str> { + api.tools.iter().map(|t| t.definition.name.as_ref()).collect() + } + + #[test] + fn document() { + let api = build( + r#"{"openapi": "3.1.0", "info": {"title": "Example", "version": "1.2"}, + "servers": [{"url": "https://api.example.com/v1"}], "paths": {}}"#, + &[], + ); + assert_eq!(api.description, "Example"); + assert_eq!(api.base_url, "https://api.example.com/v1"); + assert!(api.tools.is_empty()); + assert!(api.skipped.is_empty()); + } + + #[test] + fn get_with_parameters() { + let tool = only( + r#"{"openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/accounts/{account_id}/members": {"get": { + "x-fern-sdk-group-name": "accounts.members", "x-fern-sdk-method-name": "list", + "summary": "List members", + "parameters": [ + {"name": "account_id", "in": "path", "required": true, "schema": {"type": "string"}, + "description": "The account."}, + {"name": "page", "in": "query", "schema": {"type": "integer", "default": 1}}, + {"name": "status", "in": "query", "explode": false, + "schema": {"type": "array", "items": {"enum": ["active", "pending"]}}}, + {"name": "X-Request-Id", "in": "header", "schema": {"type": "string", "format": "uuid"}} + ], + "responses": {"200": {"description": "ok"}}}}}}"#, + ); + let param = |name: &str, location| Param { + name: name.to_owned(), + location, + }; + assert_eq!( + tool.endpoint, + Endpoint { + method: "GET".to_owned(), + path: "/accounts/{account_id}/members".to_owned(), + params: vec![ + param("account_id", Location::Path), + param("page", Location::Query { exploded: true }), + param("status", Location::Query { exploded: false }), + param("X-Request-Id", Location::Header), + ], + body: Body::None, + } + ); + assert_eq!( + definition(&tool), + json!({ + "name": "accounts_members_list", + "title": "List members", + "description": "List members", + "inputSchema": { + "type": "object", + "properties": { + "account_id": {"type": "string", "description": "The account."}, + "page": {"type": "integer", "default": 1}, + "status": {"type": "array", "items": {"enum": ["active", "pending"]}}, + "X-Request-Id": {"type": "string", "format": "uuid"} + }, + "required": ["account_id"] + }, + "annotations": { + "title": "List members", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + } + }) + ); + } + + #[test] + fn flat_body_with_model() { + let tool = only( + r##"{"openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/zones/{zone_id}/records": {"post": { + "x-fern-sdk-group-name": "dns.records", "x-fern-sdk-method-name": "create", + "parameters": [{"name": "zone_id", "in": "path", "required": true, "schema": {"type": "string"}}], + "requestBody": {"required": true, "content": {"application/json": { + "schema": {"$ref": "#/components/schemas/Record"}}}}, + "responses": {"200": {"description": "ok"}}}}}, + "components": {"schemas": {"Record": {"type": "object", "required": ["name"], + "properties": {"id": {"type": "string", "readOnly": true}, "name": {"type": "string"}, + "children": {"type": "array", "items": {"$ref": "#/components/schemas/Record"}}}}}}}"##, + ); + assert_eq!(tool.endpoint.body, Body::Flat { required: true }); + let definition = definition(&tool); + assert_eq!( + definition["annotations"], + json!({"readOnlyHint": false, "destructiveHint": false, "idempotentHint": false, "openWorldHint": true}) + ); + assert_eq!( + definition["inputSchema"], + json!({ + "type": "object", + "properties": {"zone_id": {"type": "string"}}, + "required": ["zone_id"], + "allOf": [{"$ref": "#/$defs/Record"}], + "$defs": {"Record": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "children": {"type": "array", "items": {"$ref": "#/$defs/Record"}} + }, + "required": ["name"] + }} + }) + ); + } + + #[test] + fn array_body_is_a_field() { + let tool = only( + r#"{"openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/keys": {"delete": { + "x-fern-sdk-group-name": "kv", "x-fern-sdk-method-name": "bulk_delete", + "x-forge-require-confirmation": "This operation deletes the keys.", + "requestBody": {"required": true, "description": "The keys.", "content": {"application/json": { + "schema": {"type": "array", "items": {"type": "string"}}}}}, + "responses": {"200": {"description": "ok"}}}}}}"#, + ); + assert_eq!(tool.endpoint.body, Body::Field); + let definition = definition(&tool); + assert_eq!(definition["name"], "kv_bulk_delete"); + assert_eq!(definition["description"], "This operation deletes the keys."); + assert_eq!(definition["annotations"]["destructiveHint"], true); + assert_eq!( + definition["inputSchema"], + json!({ + "type": "object", + "properties": {"body": {"type": "array", "items": {"type": "string"}, "description": "The keys."}}, + "required": ["body"] + }) + ); + } + + #[test] + fn skips() { + let built = build( + r#"{"openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": { + "/hidden": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "hidden", + "x-forge-hidden": true, "responses": {"200": {"description": "ok"}}}}, + "/upload": {"put": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "upload", + "requestBody": {"content": {"application/octet-stream": {"schema": {"type": "string"}}}}, + "responses": {"200": {"description": "ok"}}}}, + "/clash": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "clash", + "parameters": [{"name": "key", "in": "query", "schema": {"type": "string"}}, + {"name": "key", "in": "header", "schema": {"type": "string"}}], + "responses": {"200": {"description": "ok"}}}}, + "/text": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "text", + "responses": {"200": {"description": "ok", + "content": {"text/csv": {"schema": {"type": "string"}}}}}}} + }}"#, + &[], + ); + assert_eq!(names(&built), ["a_text"]); + assert_eq!( + built.skipped, + [ + r#"a.clash: two of its arguments are named "key""#, + "a.hidden: it is hidden", + "a.upload: its request body is not JSON", + ] + ); + } + + /// A document with methods in `a`, `a.b` and `c`. + const GROUPS: &str = r#"{"openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": { + "/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok"}}}}, + "/a/b": {"get": {"x-fern-sdk-group-name": "a.b", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok"}}}}, + "/c": {"get": {"x-fern-sdk-group-name": "c", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok"}}}} + }}"#; + + #[test] + fn groups() { + assert_eq!(names(&build(GROUPS, &[])), ["a_get", "a_b_get", "c_get"]); + assert_eq!(names(&build(GROUPS, &["a"])), ["a_get", "a_b_get"]); + assert_eq!(names(&build(GROUPS, &["A.B"])), ["a_b_get"]); + assert_eq!(names(&build(GROUPS, &["a.b", "c"])), ["a_b_get", "c_get"]); + } + + #[test] + fn unknown_group() { + let err = try_build(GROUPS, &["a.x"]).err().unwrap(); + assert_eq!(err.to_string(), r#"there is no group "a.x""#); + let err = try_build(GROUPS, &["a..b"]).err().unwrap(); + assert!(err.to_string().contains("is not a group"), "{err}"); + } + + #[test] + fn duplicate_names() { + let err = try_build( + r#"{"openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": { + "/a": {"get": {"x-fern-sdk-group-name": "a.b", "x-fern-sdk-method-name": "c", + "responses": {"200": {"description": "ok"}}}}, + "/b": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "b_c", + "responses": {"200": {"description": "ok"}}}} + }}"#, + &[], + ) + .err() + .unwrap(); + assert!(err.to_string().contains("are both the tool a_b_c"), "{err}"); + } +} diff --git a/tests/mcp.rs b/tests/mcp.rs new file mode 100644 index 000000000..3270023c9 --- /dev/null +++ b/tests/mcp.rs @@ -0,0 +1,334 @@ +//! Runs `forge mcp` against an API served on a local port, over both +//! transports. + +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpListener; +use std::path::PathBuf; +use std::process::{Child, ChildStdout, Command, Stdio}; +use std::sync::mpsc; +use std::thread; + +use serde_json::{Value, json}; + +/// A document with one method, `items.update`. +const SPEC: &str = r#"{ + "openapi": "3.1.0", "info": {"title": "Items", "version": "1"}, + "servers": [{"url": "https://unused.example.com"}], + "paths": {"/items/{id}": {"patch": { + "x-fern-sdk-group-name": "items", "x-fern-sdk-method-name": "update", + "summary": "Update an item", + "parameters": [ + {"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}, + {"name": "dry_run", "in": "query", "schema": {"type": "boolean"}} + ], + "requestBody": {"required": true, "content": {"application/json": {"schema": { + "type": "object", "properties": {"name": {"type": "string"}}}}}}, + "responses": {"200": {"description": "ok"}}}}} +}"#; + +/// What the API answers with. +const ITEM: &str = r#"{"id":"1","name":"new"}"#; + +/// A file that is removed when dropped. +struct TempFile(PathBuf); + +impl TempFile { + /// Writes `content` to a file named after `name` and this process. + fn new(name: &str, content: &str) -> TempFile { + let path = std::env::temp_dir().join(format!("forge-mcp-{name}-{}.json", std::process::id())); + std::fs::write(&path, content).unwrap(); + TempFile(path) + } + + fn path(&self) -> &str { + self.0.to_str().unwrap() + } +} + +impl Drop for TempFile { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } +} + +/// A child process that is killed when dropped. +struct Running(Child); + +impl Drop for Running { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +/// Starts `forge` with `args`, with `API_TOKEN` set to `server-token`. +fn forge(args: &[&str]) -> Running { + let child = Command::new(env!("CARGO_BIN_EXE_forge")) + .args(args) + .env_remove("API_BASE_URL") + .env("API_TOKEN", "server-token") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + Running(child) +} + +/// Serves one HTTP request on a local port, answering it with [`ITEM`], +/// and returns the base URL and the text of the request it receives. +fn serve_once() -> (String, mpsc::Receiver) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut request = String::new(); + let mut length = 0; + loop { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + if let Some((name, value)) = line.split_once(':') + && name.eq_ignore_ascii_case("content-length") + { + length = value.trim().parse().unwrap(); + } + request.push_str(&line); + if line == "\r\n" { + break; + } + } + let mut content = vec![0; length]; + reader.read_exact(&mut content).unwrap(); + request.push_str(&String::from_utf8(content).unwrap()); + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{ITEM}", + ITEM.len() + ) + .unwrap(); + tx.send(request).unwrap(); + }); + (url, rx) +} + +/// Checks the request the API received for `items_update`, sent with +/// `authorization`. +#[track_caller] +fn check_request(request: &str, authorization: &str) { + assert!( + request.starts_with("PATCH /items/1?dry_run=true HTTP/1.1\r\n"), + "{request}" + ); + let lower = request.to_ascii_lowercase(); + assert!( + lower.contains(&format!( + "\r\nauthorization: {}\r\n", + authorization.to_ascii_lowercase() + )), + "{request}" + ); + assert!(lower.contains("\r\ncontent-type: application/json\r\n"), "{request}"); + assert!(request.ends_with("\r\n\r\n{\"name\":\"new\"}"), "{request}"); +} + +/// The message that calls `items_update`, as request `id`. +fn call(id: u64) -> Value { + json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", + "params": {"name": "items_update", "arguments": {"id": "1", "dry_run": true, "name": "new"}}}) +} + +/// The message that initializes the session, as request 1. +fn initialize() -> Value { + json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": "2025-06-18", "capabilities": {}, + "clientInfo": {"name": "test", "version": "1"}}}) +} + +/// Checks the answers to `initialize`, `tools/list` and `tools/call`. +#[track_caller] +fn check_replies(init: &Value, list: &Value, called: &Value) { + let init = &init["result"]; + assert_eq!(init["protocolVersion"], "2025-06-18", "{init}"); + assert_eq!(init["serverInfo"]["name"], "forge", "{init}"); + assert_eq!(init["instructions"], "Items", "{init}"); + + let tools = &list["result"]["tools"]; + assert_eq!(tools.as_array().map(Vec::len), Some(1), "{list}"); + assert_eq!(tools[0]["name"], "items_update", "{list}"); + assert_eq!(tools[0]["title"], "Update an item", "{list}"); + + let result = &called["result"]; + assert_eq!(result["content"], json!([{"type": "text", "text": ITEM}]), "{called}"); + assert_eq!(result["isError"], json!(false), "{called}"); +} + +/// Reads messages from `stdout` until it has the answers to `ids`. +fn read_replies(stdout: &mut BufReader, ids: &[u64]) -> HashMap { + let mut replies = HashMap::new(); + while ids.iter().any(|id| !replies.contains_key(id)) { + let mut line = String::new(); + assert!(stdout.read_line(&mut line).unwrap() > 0, "standard output ended"); + let message: Value = serde_json::from_str(&line).unwrap(); + if let Some(id) = message["id"].as_u64() { + replies.insert(id, message); + } + } + replies +} + +#[test] +fn stdio() { + let (url, requests) = serve_once(); + let spec = TempFile::new("stdio", SPEC); + let mut forge = forge(&["mcp", spec.path(), "--transport", "stdio", "--base-url", &url]); + let mut stdin = forge.0.stdin.take().unwrap(); + let mut stdout = BufReader::new(forge.0.stdout.take().unwrap()); + + writeln!(stdin, "{}", initialize()).unwrap(); + let init = read_replies(&mut stdout, &[1]).remove(&1).unwrap(); + writeln!( + stdin, + "{}", + json!({"jsonrpc": "2.0", "method": "notifications/initialized"}) + ) + .unwrap(); + writeln!(stdin, "{}", json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})).unwrap(); + writeln!(stdin, "{}", call(3)).unwrap(); + let mut replies = read_replies(&mut stdout, &[2, 3]); + check_replies(&init, &replies.remove(&2).unwrap(), &replies.remove(&3).unwrap()); + check_request(&requests.recv().unwrap(), "Bearer server-token"); + + // The server stops when the client closes standard input. + drop(stdin); + let status = forge.0.wait().unwrap(); + assert!(status.success(), "{status}"); +} + +/// Returns the URL that `forge` serves MCP at, from what it prints. +fn http_url(forge: &mut Running) -> String { + let stderr = BufReader::new(forge.0.stderr.take().unwrap()); + for line in stderr.lines() { + let line = line.unwrap(); + if let Some((_, url)) = line.split_once(" at ") { + return url.to_owned(); + } + } + panic!("forge did not say where it serves MCP"); +} + +/// Posts `message` to the MCP server at `url`, in `session`, and returns +/// the response's session ID and the message it answers with, if any. +async fn post( + http: &reqwest::Client, + url: &str, + session: Option<&str>, + message: &Value, +) -> (Option, Option) { + let mut request = http + .post(url) + .header("Accept", "application/json, text/event-stream") + .header("Content-Type", "application/json") + .header("MCP-Protocol-Version", "2025-06-18") + .header("Authorization", "Bearer client-token") + .body(message.to_string()); + if let Some(session) = session { + request = request.header("Mcp-Session-Id", session); + } + let response = request.send().await.unwrap(); + let status = response.status(); + let session = response + .headers() + .get("mcp-session-id") + .map(|id| id.to_str().unwrap().to_owned()); + let event_stream = response + .headers() + .get("content-type") + .is_some_and(|ty| ty.to_str().unwrap().starts_with("text/event-stream")); + let body = response.text().await.unwrap(); + assert!(status.is_success(), "{status}: {body}"); + // The answer is either JSON, or the data of an event in a stream. + let reply = if event_stream { + body.lines() + .filter_map(|line| line.strip_prefix("data:")) + .map(str::trim) + .find(|data| !data.is_empty()) + .map(|data| serde_json::from_str(data).unwrap()) + } else if body.is_empty() { + None + } else { + Some(serde_json::from_str(&body).unwrap()) + }; + (session, reply) +} + +#[tokio::test] +async fn http() { + let (url, requests) = serve_once(); + let spec = TempFile::new("http", SPEC); + let mut forge = forge(&["mcp", spec.path(), "--listen", "127.0.0.1:0", "--base-url", &url]); + let mcp = http_url(&mut forge); + assert!(mcp.starts_with("http://127.0.0.1:") && mcp.ends_with("/mcp"), "{mcp}"); + + let http = reqwest::Client::new(); + let (session, init) = post(&http, &mcp, None, &initialize()).await; + let session = session.expect("no session ID"); + let initialized = json!({"jsonrpc": "2.0", "method": "notifications/initialized"}); + let (_, none) = post(&http, &mcp, Some(&session), &initialized).await; + assert_eq!(none, None); + let list = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}); + let (_, list) = post(&http, &mcp, Some(&session), &list).await; + let (_, called) = post(&http, &mcp, Some(&session), &call(3)).await; + check_replies(&init.unwrap(), &list.unwrap(), &called.unwrap()); + // The client's own Authorization header wins over API_TOKEN. + check_request(&requests.recv().unwrap(), "Bearer client-token"); +} + +#[test] +fn http_is_the_default() { + let spec = TempFile::new("default", SPEC); + let mut forge = forge(&["mcp", spec.path(), "--listen", "127.0.0.1:0"]); + let url = http_url(&mut forge); + assert!(url.starts_with("http://127.0.0.1:"), "{url}"); +} + +#[test] +fn stdio_cannot_read_the_document_from_stdin() { + let out = Command::new(env!("CARGO_BIN_EXE_forge")) + .args(["mcp", "-", "--transport", "stdio"]) + .output() + .unwrap(); + assert!(!out.status.success()); + let err = String::from_utf8(out.stderr).unwrap(); + assert!(err.contains("cannot read the document there"), "{err}"); +} + +#[test] +fn unknown_group() { + let spec = TempFile::new("group", SPEC); + let out = Command::new(env!("CARGO_BIN_EXE_forge")) + .args(["mcp", spec.path(), "--group", "nope"]) + .output() + .unwrap(); + assert!(!out.status.success()); + let err = String::from_utf8(out.stderr).unwrap(); + assert!(err.contains(r#"there is no group "nope""#), "{err}"); +} + +#[test] +fn needs_a_base_url() { + let spec = TempFile::new( + "base-url", + r#"{"openapi": "3.1.0", "info": {"title": "t", "version": "1"}, "paths": {}}"#, + ); + let out = Command::new(env!("CARGO_BIN_EXE_forge")) + .args(["mcp", spec.path()]) + .env_remove("API_BASE_URL") + .output() + .unwrap(); + assert!(!out.status.success()); + let err = String::from_utf8(out.stderr).unwrap(); + assert!(err.contains("--base-url"), "{err}"); +}