diff --git a/Cargo.lock b/Cargo.lock index 3ed658eebc..7921dc7308 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -920,7 +920,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -2271,7 +2271,7 @@ dependencies = [ "merge", "pretty_assertions", "regex", - "reqwest 0.12.28", + "reqwest 0.13.3", "schemars 1.2.1", "serde", "serde_json", @@ -2403,7 +2403,7 @@ dependencies = [ "nom", "pin-project-lite", "pin-utils", - "reqwest 0.12.28", + "reqwest 0.13.3", "rocket", "thiserror 2.0.18", "tokio", @@ -2463,6 +2463,7 @@ dependencies = [ "forge_domain", "forge_eventsource", "forge_fs", + "forge_reqwest", "forge_select", "forge_services", "forge_snaps", @@ -2475,7 +2476,7 @@ dependencies = [ "oauth2", "open", "pretty_assertions", - "reqwest 0.12.28", + "reqwest 0.13.3", "rmcp", "schemars 1.2.1", "serde", @@ -2632,7 +2633,7 @@ dependencies = [ "prost", "prost-types", "regex", - "reqwest 0.12.28", + "reqwest 0.13.3", "schemars 1.2.1", "serde", "serde_json", @@ -2649,6 +2650,14 @@ dependencies = [ "url", ] +[[package]] +name = "forge_reqwest" +version = "0.1.0" +dependencies = [ + "reqwest 0.13.3", + "webpki-root-certs", +] + [[package]] name = "forge_select" version = "0.1.0" @@ -2684,6 +2693,7 @@ dependencies = [ "forge_domain", "forge_eventsource", "forge_fs", + "forge_reqwest", "forge_snaps", "forge_stream", "forge_test_kit", @@ -2703,7 +2713,7 @@ dependencies = [ "oauth2", "pretty_assertions", "regex", - "reqwest 0.12.28", + "reqwest 0.13.3", "serde", "serde_json", "serde_urlencoded", @@ -2798,13 +2808,14 @@ dependencies = [ "derive_more", "dirs", "forge_domain", + "forge_reqwest", "http 1.4.0", "lazy_static", "machineid-rs", "posthog-rs", "pretty_assertions", "regex", - "reqwest 0.12.28", + "reqwest 0.13.3", "serde", "serde_json", "sysinfo 0.38.4", @@ -2998,7 +3009,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ "rustix 1.1.4", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -4039,7 +4050,7 @@ dependencies = [ "hmac 0.13.0", "http 1.4.0", "jsonwebtoken", - "reqwest 0.13.2", + "reqwest 0.13.3", "rustc_version", "rustls 0.23.40", "rustls-pki-types", @@ -4634,7 +4645,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core", ] [[package]] @@ -4893,7 +4904,7 @@ dependencies = [ "socket2 0.6.3", "widestring", "windows-registry", - "windows-result 0.4.1", + "windows-result", "windows-sys 0.61.2", ] @@ -5590,18 +5601,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "nix" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" -dependencies = [ - "bitflags 2.11.0", - "cfg-if", - "cfg_aliases", - "libc", -] - [[package]] name = "nix" version = "0.31.2" @@ -5717,7 +5716,6 @@ dependencies = [ "getrandom 0.2.17", "http 1.4.0", "rand 0.8.5", - "reqwest 0.12.28", "serde", "serde_json", "serde_path_to_error", @@ -5953,7 +5951,7 @@ dependencies = [ "libc", "redox_syscall 0.5.18", "smallvec", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -6189,7 +6187,7 @@ dependencies = [ "chrono", "derive_builder 0.20.2", "regex", - "reqwest 0.13.2", + "reqwest 0.13.3", "semver", "serde", "serde_json", @@ -6295,16 +6293,16 @@ dependencies = [ [[package]] name = "process-wrap" -version = "8.2.1" +version = "9.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3ef4f2f0422f23a82ec9f628ea2acd12871c81a9362b02c43c1aa86acfc3ba1" +checksum = "2e842efad9119158434d193c6682e2ebee4b44d6ad801d7b349623b3f57cdf55" dependencies = [ "futures", "indexmap 2.14.0", - "nix 0.30.1", + "nix", "tokio", "tracing", - "windows 0.61.3", + "windows 0.62.2", ] [[package]] @@ -6770,7 +6768,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.4.2", "web-sys", "winreg 0.50.0", ] @@ -6786,8 +6784,6 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2 0.4.13", - "hickory-resolver", "http 1.4.0", "http-body 1.0.1", "http-body-util", @@ -6796,7 +6792,6 @@ dependencies = [ "hyper-util", "js-sys", "log", - "once_cell", "percent-encoding", "pin-project-lite", "quinn", @@ -6808,29 +6803,29 @@ dependencies = [ "sync_wrapper 1.0.2", "tokio", "tokio-rustls 0.26.4", - "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", "web-sys", "webpki-roots", ] [[package]] name = "reqwest" -version = "0.13.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" dependencies = [ "base64 0.22.1", "bytes", "futures-channel", "futures-core", "futures-util", + "h2 0.4.13", + "hickory-resolver", "http 1.4.0", "http-body 1.0.1", "http-body-util", @@ -6839,6 +6834,7 @@ dependencies = [ "hyper-util", "js-sys", "log", + "once_cell", "percent-encoding", "pin-project-lite", "quinn", @@ -6851,12 +6847,14 @@ dependencies = [ "sync_wrapper 1.0.2", "tokio", "tokio-rustls 0.26.4", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams 0.5.0", "web-sys", ] @@ -6882,9 +6880,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "0.10.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b18323edc657390a6ed4d7a9110b0dec2dc3ed128eb2a123edfbafabdbddc5" +checksum = "67d69668de0b0ccd9cc435f700f3b39a7861863cf37a15e1f304ea78688a4826" dependencies = [ "async-trait", "base64 0.22.1", @@ -6895,7 +6893,7 @@ dependencies = [ "pastey", "pin-project-lite", "process-wrap", - "reqwest 0.12.28", + "reqwest 0.13.3", "rmcp-macros", "schemars 1.2.1", "serde", @@ -6911,11 +6909,11 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "0.10.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c75d0a62676bf8c8003c4e3c348e2ceb6a7b3e48323681aaf177fdccdac2ce50" +checksum = "48fdc01c81097b0aed18633e676e269fefa3a78ec1df56b4fe597c1241b92025" dependencies = [ - "darling 0.21.3", + "darling 0.23.0", "proc-macro2", "quote", "serde_json", @@ -7205,7 +7203,7 @@ dependencies = [ "libc", "log", "memchr", - "nix 0.31.2", + "nix", "radix_trie", "unicode-segmentation", "unicode-width 0.2.2", @@ -9158,6 +9156,19 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" @@ -9286,38 +9297,16 @@ dependencies = [ "windows-targets 0.48.5", ] -[[package]] -name = "windows" -version = "0.61.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" -dependencies = [ - "windows-collections 0.2.0", - "windows-core 0.61.2", - "windows-future 0.2.1", - "windows-link 0.1.3", - "windows-numerics 0.2.0", -] - [[package]] name = "windows" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-collections 0.3.2", - "windows-core 0.62.2", - "windows-future 0.3.2", - "windows-numerics 0.3.1", -] - -[[package]] -name = "windows-collections" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" -dependencies = [ - "windows-core 0.61.2", + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", ] [[package]] @@ -9326,20 +9315,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-core 0.62.2", -] - -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", + "windows-core", ] [[package]] @@ -9350,20 +9326,9 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement 0.60.2", "windows-interface 0.59.3", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - -[[package]] -name = "windows-future" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", - "windows-threading 0.1.0", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] @@ -9372,9 +9337,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core 0.62.2", - "windows-link 0.2.1", - "windows-threading 0.2.1", + "windows-core", + "windows-link", + "windows-threading", ] [[package]] @@ -9421,36 +9386,20 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-numerics" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", -] - [[package]] name = "windows-numerics" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core 0.62.2", - "windows-link 0.2.1", + "windows-core", + "windows-link", ] [[package]] @@ -9459,18 +9408,9 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link 0.1.3", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] @@ -9479,16 +9419,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", + "windows-link", ] [[package]] @@ -9497,7 +9428,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -9542,7 +9473,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -9591,22 +9522,13 @@ dependencies = [ "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-threading" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" -dependencies = [ - "windows-link 0.1.3", -] - [[package]] name = "windows-threading" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 63056488e7..943d8df0af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,13 +67,14 @@ quote = "1.0" reedline = "0.47.0" rustyline = "18.0.0" regex = "1.12.3" -reqwest = { version = "0.12.23", features = [ +reqwest = { version = "0.13.3", features = [ "json", - "rustls-tls", + "rustls", "hickory-dns", "http2", ], default-features = false } rustls = { version = "0.23", features = ["ring"], default-features = false } +webpki-root-certs = "1.0" include_dir = "0.7.4" schemars = "1.2" serde = { version = "1.0.217", features = ["derive"] } @@ -122,9 +123,8 @@ whoami = "2.1.0" fnv_rs = "0.4.3" merge = { version = "0.2", features = ["derive"] } hex = "0.4.3" -rmcp = { version = "0.10.0", features = [ +rmcp = { version = "1.5", features = [ "client", - "transport-sse-client-reqwest", "transport-child-process", "transport-streamable-http-client-reqwest", "auth", @@ -166,3 +166,4 @@ forge_markdown_stream = { path = "crates/forge_markdown_stream" } forge_config = { path = "crates/forge_config" } forge_eventsource = { path = "crates/forge_eventsource" } forge_eventsource_stream = { path = "crates/forge_eventsource_stream" } +forge_reqwest = { path = "crates/forge_reqwest" } diff --git a/crates/forge_eventsource/Cargo.toml b/crates/forge_eventsource/Cargo.toml index 4f1aad9d8f..efef78ebba 100644 --- a/crates/forge_eventsource/Cargo.toml +++ b/crates/forge_eventsource/Cargo.toml @@ -6,7 +6,7 @@ rust-version.workspace = true [dependencies] forge_eventsource_stream.workspace = true -reqwest = { version = "0.12.0", default-features = false, features = ["stream"] } +reqwest = { workspace = true, features = ["stream"] } futures-core = "0.3.5" pin-project-lite = "0.2.8" nom = "8.0.0" diff --git a/crates/forge_infra/Cargo.toml b/crates/forge_infra/Cargo.toml index f907fa33db..c87b31ff8a 100644 --- a/crates/forge_infra/Cargo.toml +++ b/crates/forge_infra/Cargo.toml @@ -33,6 +33,7 @@ forge_walker.workspace = true forge_eventsource.workspace = true +forge_reqwest.workspace = true glob.workspace = true futures.workspace = true diesel = { version= "2.3.7", features = ["sqlite", "r2d2", "chrono"] } @@ -41,7 +42,7 @@ diesel_migrations = "2.2.0" chrono = { version = "0.4", features = ["serde"] } cacache = { version = "13.1.0", features = ["tokio-runtime"], default-features = false } serde.workspace = true -oauth2 = { version = "5.0", features = ["reqwest"] } +oauth2 = { version = "5.0", default-features = false } serde_urlencoded = "0.7.1" base64.workspace = true http.workspace = true diff --git a/crates/forge_infra/src/auth/mcp_token_storage.rs b/crates/forge_infra/src/auth/mcp_token_storage.rs index 544833fe40..8fbb44904d 100644 --- a/crates/forge_infra/src/auth/mcp_token_storage.rs +++ b/crates/forge_infra/src/auth/mcp_token_storage.rs @@ -98,11 +98,11 @@ impl CredentialStore for McpTokenStorage { if let Some(entry) = store.get(&self.server_url) { use oauth2::basic::BasicTokenType; use oauth2::{AccessToken, RefreshToken}; - use rmcp::transport::auth::OAuthTokenResponse; + use rmcp::transport::auth::{OAuthTokenResponse, VendorExtraTokenFields}; let access_token = AccessToken::new(entry.tokens.access_token.clone()); let token_type = BasicTokenType::Bearer; - let extra_fields = oauth2::EmptyExtraTokenFields {}; + let extra_fields = VendorExtraTokenFields::default(); let mut token_response = OAuthTokenResponse::new(access_token, token_type, extra_fields); @@ -127,14 +127,23 @@ impl CredentialStore for McpTokenStorage { } } - Ok(Some(StoredCredentials { - client_id: entry + let granted_scopes = entry + .tokens + .scope + .as_deref() + .map(|s| s.split_whitespace().map(str::to_string).collect()) + .unwrap_or_default(); + + Ok(Some(StoredCredentials::new( + entry .client_registration .as_ref() .map(|r| r.client_id.clone()) .unwrap_or_default(), - token_response: Some(token_response), - })) + Some(token_response), + granted_scopes, + None, + ))) } else { Ok(None) } diff --git a/crates/forge_infra/src/http.rs b/crates/forge_infra/src/http.rs index 228cc902ef..3ec4ed2af1 100644 --- a/crates/forge_infra/src/http.rs +++ b/crates/forge_infra/src/http.rs @@ -1,6 +1,6 @@ use std::fs; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use std::time::Duration; use anyhow::Context; @@ -19,7 +19,8 @@ const VERSION: &str = match option_env!("APP_VERSION") { }; pub struct ForgeHttpInfra { - client: Client, + client: OnceLock, + config: ForgeConfig, debug_requests: Option, file: Arc, } @@ -36,88 +37,103 @@ fn to_reqwest_tls(tls: TlsVersion) -> reqwest::tls::Version { impl ForgeHttpInfra { /// Creates a new [`ForgeHttpInfra`] from a resolved [`ForgeConfig`]. + /// + /// The underlying HTTP client is built lazily on first use. pub fn new(config: ForgeConfig, file_writer: Arc) -> Self { - let http = config.http.unwrap_or(forge_config::HttpConfig { - connect_timeout_secs: 30, - read_timeout_secs: 900, - pool_idle_timeout_secs: 90, - pool_max_idle_per_host: 5, - max_redirects: 10, - hickory: false, - tls_backend: TlsBackend::Default, - min_tls_version: None, - max_tls_version: None, - adaptive_window: true, - keep_alive_interval_secs: Some(60), - keep_alive_timeout_secs: 10, - keep_alive_while_idle: true, - accept_invalid_certs: false, - root_cert_paths: None, - }); - - let mut client = reqwest::Client::builder() - .connect_timeout(Duration::from_secs(http.connect_timeout_secs)) - .read_timeout(Duration::from_secs(http.read_timeout_secs)) - .pool_idle_timeout(Duration::from_secs(http.pool_idle_timeout_secs)) - .pool_max_idle_per_host(http.pool_max_idle_per_host) - .redirect(Policy::limited(http.max_redirects)) - .hickory_dns(http.hickory) - // HTTP/2 configuration from config - .http2_adaptive_window(http.adaptive_window) - .http2_keep_alive_interval(http.keep_alive_interval_secs.map(Duration::from_secs)) - .http2_keep_alive_timeout(Duration::from_secs(http.keep_alive_timeout_secs)) - .http2_keep_alive_while_idle(http.keep_alive_while_idle); - - // Add root certificates from config - if let Some(ref cert_paths) = http.root_cert_paths { - for cert_path in cert_paths { - match fs::read(cert_path) { - Ok(buf) => { - if let Ok(cert) = Certificate::from_pem(&buf) { - client = client.add_root_certificate(cert); - } else if let Ok(cert) = Certificate::from_der(&buf) { - client = client.add_root_certificate(cert); - } else { + Self { + debug_requests: config.debug_requests.clone(), + client: OnceLock::new(), + config, + file: file_writer, + } + } + + /// Returns a reference to the underlying [`Client`], building it on first + /// call. + fn client(&self) -> &Client { + self.client.get_or_init(|| { + let http = self + .config + .http + .clone() + .unwrap_or(forge_config::HttpConfig { + connect_timeout_secs: 30, + read_timeout_secs: 900, + pool_idle_timeout_secs: 90, + pool_max_idle_per_host: 5, + max_redirects: 10, + hickory: false, + tls_backend: TlsBackend::Default, + min_tls_version: None, + max_tls_version: None, + adaptive_window: true, + keep_alive_interval_secs: Some(60), + keep_alive_timeout_secs: 10, + keep_alive_while_idle: true, + accept_invalid_certs: false, + root_cert_paths: None, + }); + + let mut client = forge_reqwest::builder() + .connect_timeout(Duration::from_secs(http.connect_timeout_secs)) + .read_timeout(Duration::from_secs(http.read_timeout_secs)) + .pool_idle_timeout(Duration::from_secs(http.pool_idle_timeout_secs)) + .pool_max_idle_per_host(http.pool_max_idle_per_host) + .redirect(Policy::limited(http.max_redirects)) + .hickory_dns(http.hickory) + // HTTP/2 configuration from config + .http2_adaptive_window(http.adaptive_window) + .http2_keep_alive_interval(http.keep_alive_interval_secs.map(Duration::from_secs)) + .http2_keep_alive_timeout(Duration::from_secs(http.keep_alive_timeout_secs)) + .http2_keep_alive_while_idle(http.keep_alive_while_idle); + + // Add root certificates from config + if let Some(ref cert_paths) = http.root_cert_paths { + for cert_path in cert_paths { + match fs::read(cert_path) { + Ok(buf) => { + if let Ok(cert) = Certificate::from_pem(&buf) { + client = client.add_root_certificate(cert); + } else if let Ok(cert) = Certificate::from_der(&buf) { + client = client.add_root_certificate(cert); + } else { + warn!( + "Failed to parse certificate as PEM or DER format, cert = {}", + cert_path + ); + } + } + Err(error) => { warn!( - "Failed to parse certificate as PEM or DER format, cert = {}", - cert_path + "Failed to read certificate file, path = {}, error = {}", + cert_path, error ); } } - Err(error) => { - warn!( - "Failed to read certificate file, path = {}, error = {}", - cert_path, error - ); - } } } - } - if http.accept_invalid_certs { - client = client.danger_accept_invalid_certs(true); - } + if http.accept_invalid_certs { + client = client.danger_accept_invalid_certs(true); + } - if let Some(version) = http.min_tls_version { - client = client.min_tls_version(to_reqwest_tls(version)); - } + if let Some(version) = http.min_tls_version { + client = client.min_tls_version(to_reqwest_tls(version)); + } - if let Some(version) = http.max_tls_version { - client = client.max_tls_version(to_reqwest_tls(version)); - } + if let Some(version) = http.max_tls_version { + client = client.max_tls_version(to_reqwest_tls(version)); + } - match http.tls_backend { - TlsBackend::Rustls => { - client = client.use_rustls_tls(); + match http.tls_backend { + TlsBackend::Rustls => { + client = client.use_rustls_tls(); + } + TlsBackend::Default => {} } - TlsBackend::Default => {} - } - Self { - debug_requests: config.debug_requests, - client: client.build().unwrap(), - file: file_writer, - } + client.build().unwrap() + }) } async fn get(&self, url: &Url, headers: Option) -> anyhow::Result { @@ -162,7 +178,7 @@ impl ForgeHttpInfra { where B: FnOnce(&Client) -> reqwest::RequestBuilder, { - let response = request_builder(&self.client) + let response = request_builder(self.client()) .send() .await .with_context(|| format_http_context(None, method, url))?; @@ -256,7 +272,7 @@ impl ForgeHttpInfra { self.write_debug_request(&body); - self.client + self.client() .post(url.clone()) .headers(request_headers) .body(body) diff --git a/crates/forge_infra/src/mcp_client.rs b/crates/forge_infra/src/mcp_client.rs index 511771833f..8184776c4e 100644 --- a/crates/forge_infra/src/mcp_client.rs +++ b/crates/forge_infra/src/mcp_client.rs @@ -1,4 +1,3 @@ -use std::borrow::Cow; use std::collections::BTreeMap; use std::future::Future; use std::str::FromStr; @@ -12,11 +11,12 @@ use forge_domain::{ }; use reqwest::Client; use reqwest::header::{HeaderName, HeaderValue}; -use rmcp::model::{CallToolRequestParam, ClientInfo, Implementation, InitializeRequestParam}; +use rmcp::model::{CallToolRequestParams, ClientInfo, Implementation, InitializeRequestParams}; use rmcp::service::RunningService; -use rmcp::transport::sse_client::SseClientConfig; -use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; -use rmcp::transport::{SseClientTransport, StreamableHttpClientTransport, TokioChildProcess}; +use rmcp::transport::TokioChildProcess; +use rmcp::transport::streamable_http_client::{ + StreamableHttpClientTransport, StreamableHttpClientTransportConfig, +}; use rmcp::{RoleClient, ServiceExt}; use schemars::Schema; use serde_json::Value; @@ -30,12 +30,12 @@ const VERSION: &str = match option_env!("APP_VERSION") { None => env!("CARGO_PKG_VERSION"), }; -type RmcpClient = RunningService; +type RmcpClient = RunningService; #[derive(Clone)] pub struct ForgeMcpClient { client: Arc>>>, - http_client: Arc, + http_client: Arc>>, config: McpServerConfig, env_vars: BTreeMap, environment: Environment, @@ -62,30 +62,9 @@ impl ForgeMcpClient { env_vars: &BTreeMap, environment: Environment, ) -> Self { - // Try to resolve config early so we can extract headers for the HTTP client. - // If resolution fails, fall back to a plain client (headers will be missing - // but the error will surface when create_connection is called). - let resolved = resolve_http_templates( - match &config { - McpServerConfig::Http(http) => http.clone(), - McpServerConfig::Stdio(_) => McpHttpServer { - url: String::new(), - headers: BTreeMap::new(), - timeout: None, - disable: false, - oauth: forge_domain::McpOAuthSetting::default(), - }, - }, - env_vars, - ); - - let http_client = resolved - .and_then(|http| Self::build_http_client(&http)) - .unwrap_or_default(); - Self { client: Default::default(), - http_client: Arc::new(http_client), + http_client: Arc::new(OnceLock::new()), config, env_vars: env_vars.clone(), environment, @@ -107,17 +86,7 @@ impl ForgeMcpClient { } fn client_info(&self) -> ClientInfo { - ClientInfo { - protocol_version: Default::default(), - capabilities: Default::default(), - client_info: Implementation { - name: "Forge".to_string(), - version: VERSION.to_string(), - icons: None, - title: None, - website_url: None, - }, - } + ClientInfo::new(Default::default(), Implementation::new("Forge", VERSION)) } /// Connects to the MCP server. If `force` is true, it will reconnect even @@ -219,23 +188,12 @@ impl ForgeMcpClient { &self, http: &McpHttpServer, ) -> anyhow::Result { - // Try HTTP first, fall back to SSE if it fails let client = self.reqwest_client(); let transport = StreamableHttpClientTransport::with_client( client.as_ref().clone(), StreamableHttpClientTransportConfig::with_uri(http.url.clone()), ); - match self.client_info().serve(transport).await { - Ok(client) => Ok(client), - Err(_e) => { - let transport = SseClientTransport::start_with_client( - client.as_ref().clone(), - SseClientConfig { sse_endpoint: http.url.clone().into(), ..Default::default() }, - ) - .await?; - Ok(self.client_info().serve(transport).await?) - } - } + Ok(self.client_info().serve(transport).await?) } /// Create an OAuth-enabled connection using rmcp's OAuth support. @@ -366,12 +324,23 @@ impl ForgeMcpClient { .map_err(|e| anyhow::anyhow!("Failed to get credentials: {}", e))?; { + use oauth2::TokenResponse; use rmcp::transport::auth::CredentialStore; let save_store = McpTokenStorage::new(http.url.clone(), self.environment.clone()); - let stored = rmcp::transport::auth::StoredCredentials { - client_id: credentials.0, - token_response: credentials.1, - }; + // Prefer scopes granted by the server (from the token response); fall + // back to the scopes we requested if the server didn't echo them back. + let granted_scopes = credentials + .1 + .as_ref() + .and_then(|t| t.scopes()) + .map(|s| s.iter().map(|s| s.to_string()).collect::>()) + .unwrap_or_else(|| oauth_config.scopes.clone()); + let stored = rmcp::transport::auth::StoredCredentials::new( + credentials.0, + credentials.1, + granted_scopes, + None, + ); save_store .save(stored) .await @@ -500,7 +469,28 @@ impl ForgeMcpClient { // to prevent file descriptor leaks. Each reqwest::Client manages its // own connection pool, so creating new clients for each connection // leads to "Too many open files" errors. - self.http_client.clone() + self.http_client + .get_or_init(|| { + let resolved = resolve_http_templates( + match &self.config { + McpServerConfig::Http(http) => http.clone(), + McpServerConfig::Stdio(_) => McpHttpServer { + url: String::new(), + headers: BTreeMap::new(), + timeout: None, + disable: false, + oauth: forge_domain::McpOAuthSetting::default(), + }, + }, + &self.env_vars, + ); + Arc::new( + resolved + .and_then(|http| Self::build_http_client(&http)) + .unwrap_or_default(), + ) + }) + .clone() } async fn list(&self) -> anyhow::Result> { @@ -526,16 +516,11 @@ impl ForgeMcpClient { async fn call(&self, tool_name: &ToolName, input: &Value) -> anyhow::Result { let client = self.connect().await?; - let result = client - .call_tool(CallToolRequestParam { - name: Cow::Owned(tool_name.to_string()), - arguments: if let Value::Object(args) = input { - Some(args.clone()) - } else { - None - }, - }) - .await?; + let mut params = CallToolRequestParams::new(tool_name.to_string()); + if let Value::Object(args) = input { + params = params.with_arguments(args.clone()); + } + let result = client.call_tool(params).await?; let tool_contents: Vec = result .content @@ -734,10 +719,21 @@ pub async fn mcp_auth(server_url: &str, env: &Environment) -> anyhow::Result<()> .map_err(|e| anyhow::anyhow!("Failed to get credentials: {}", e))?; let save_store = McpTokenStorage::new(server_url.to_string(), env.clone()); - let stored = rmcp::transport::auth::StoredCredentials { - client_id: credentials.0, - token_response: credentials.1, + let granted_scopes = { + use oauth2::TokenResponse; + credentials + .1 + .as_ref() + .and_then(|t| t.scopes()) + .map(|s| s.iter().map(|s| s.to_string()).collect::>()) + .unwrap_or_default() }; + let stored = rmcp::transport::auth::StoredCredentials::new( + credentials.0, + credentials.1, + granted_scopes, + None, + ); save_store .save(stored) .await diff --git a/crates/forge_reqwest/Cargo.toml b/crates/forge_reqwest/Cargo.toml new file mode 100644 index 0000000000..d235fcadf9 --- /dev/null +++ b/crates/forge_reqwest/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "forge_reqwest" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true + +[dependencies] +reqwest.workspace = true +webpki-root-certs.workspace = true diff --git a/crates/forge_reqwest/src/lib.rs b/crates/forge_reqwest/src/lib.rs new file mode 100644 index 0000000000..ea494841c2 --- /dev/null +++ b/crates/forge_reqwest/src/lib.rs @@ -0,0 +1,26 @@ +//! Centralized [`reqwest::Client`] construction for the workspace. +//! +//! `reqwest 0.13` removed the compiled-in `rustls-tls-webpki-roots` feature +//! and switched its default trust source to `rustls-platform-verifier`, which +//! synchronously parses the OS trust store on every `Client::build()` +//! (~38 ms on Linux). Per the upstream `0.13.0` changelog, the recommended +//! replacement is to call `ClientBuilder::tls_certs_only(your_roots)`. +//! +//! All `reqwest::Client`s in the codebase should be built from +//! [`builder`] so the trust-source decision lives in one place and the +//! platform-verifier cost is avoided on cold-start paths. + +use reqwest::ClientBuilder; +use reqwest::tls::Certificate; + +/// Returns a [`reqwest::ClientBuilder`] preconfigured with the bundled +/// Mozilla webpki root CAs. +pub fn builder() -> ClientBuilder { + reqwest::Client::builder().tls_certs_only(webpki_root_certs()) +} + +fn webpki_root_certs() -> impl IntoIterator { + webpki_root_certs::TLS_SERVER_ROOT_CERTS + .iter() + .filter_map(|der| Certificate::from_der(der.as_ref()).ok()) +} diff --git a/crates/forge_services/Cargo.toml b/crates/forge_services/Cargo.toml index 5e3be29337..d38ba80197 100644 --- a/crates/forge_services/Cargo.toml +++ b/crates/forge_services/Cargo.toml @@ -23,6 +23,7 @@ dashmap.workspace = true anyhow.workspace = true futures.workspace = true reqwest.workspace = true +forge_reqwest.workspace = true derive_more.workspace = true regex.workspace = true backon.workspace = true @@ -48,7 +49,7 @@ forge_eventsource.workspace = true lazy_static = "1.5.0" forge_domain.workspace = true forge_config.workspace = true -oauth2 = { version = "5.0", features = ["reqwest"] } +oauth2 = { version = "5.0", default-features = false } serde_urlencoded = "0.7.1" http.workspace = true infer.workspace = true diff --git a/crates/forge_services/src/tool_services/fetch.rs b/crates/forge_services/src/tool_services/fetch.rs index 273f614df2..8c7ebf9b3f 100644 --- a/crates/forge_services/src/tool_services/fetch.rs +++ b/crates/forge_services/src/tool_services/fetch.rs @@ -23,7 +23,7 @@ impl Default for ForgeFetch { impl ForgeFetch { pub fn new() -> Self { - Self { client: Client::new() } + Self { client: forge_reqwest::builder().build().unwrap_or_default() } } } diff --git a/crates/forge_tracker/Cargo.toml b/crates/forge_tracker/Cargo.toml index 2ea2af50ce..a6e75ed7ae 100644 --- a/crates/forge_tracker/Cargo.toml +++ b/crates/forge_tracker/Cargo.toml @@ -6,6 +6,7 @@ rust-version.workspace = true [dependencies] reqwest.workspace = true +forge_reqwest.workspace = true derive_more.workspace = true url.workspace = true serde.workspace = true diff --git a/crates/forge_tracker/src/collect/posthog.rs b/crates/forge_tracker/src/collect/posthog.rs index d7c3213a29..7a36939440 100644 --- a/crates/forge_tracker/src/collect/posthog.rs +++ b/crates/forge_tracker/src/collect/posthog.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::sync::OnceLock; use std::time::Duration; use chrono::NaiveDateTime; @@ -13,21 +14,24 @@ use crate::Event; pub struct Tracker { api_secret: &'static str, - client: Client, + client: OnceLock, } impl Tracker { pub fn new(api_secret: &'static str) -> Self { - // Configure HTTP client with connection pooling similar to forge_provider - let client = Client::builder() - .connect_timeout(Duration::from_secs(10)) - .read_timeout(Duration::from_secs(30)) - .pool_idle_timeout(Duration::from_secs(90)) - .pool_max_idle_per_host(5) - .build() - .expect("Failed to build HTTP client for PostHog tracker"); + Self { api_secret, client: OnceLock::new() } + } - Self { api_secret, client } + fn client(&self) -> &Client { + self.client.get_or_init(|| { + forge_reqwest::builder() + .connect_timeout(Duration::from_secs(10)) + .read_timeout(Duration::from_secs(30)) + .pool_idle_timeout(Duration::from_secs(90)) + .pool_max_idle_per_host(5) + .build() + .expect("Failed to build HTTP client for PostHog tracker") + }) } } @@ -96,7 +100,7 @@ impl Collect for Tracker { // TODO: move http request to a dispatch async fn collect(&self, event: Event) -> Result<()> { let request = self.create_request(event)?; - self.client.execute(request).await?; + self.client().execute(request).await?; Ok(()) }