Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
313 changes: 122 additions & 191 deletions Cargo.lock

Large diffs are not rendered by default.

9 changes: 5 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,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"] }
Expand Down Expand Up @@ -121,9 +122,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",
Expand Down Expand Up @@ -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" }
2 changes: 1 addition & 1 deletion crates/forge_eventsource/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion crates/forge_infra/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand All @@ -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
Expand Down
21 changes: 15 additions & 6 deletions crates/forge_infra/src/auth/mcp_token_storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion crates/forge_infra/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ impl<F: forge_app::FileWriterInfra + 'static> ForgeHttpInfra<F> {
root_cert_paths: None,
});

let mut client = reqwest::Client::builder()
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))
Expand Down
88 changes: 42 additions & 46 deletions crates/forge_infra/src/mcp_client.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::future::Future;
use std::str::FromStr;
Expand All @@ -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;
Expand All @@ -30,7 +30,7 @@ const VERSION: &str = match option_env!("APP_VERSION") {
None => env!("CARGO_PKG_VERSION"),
};

type RmcpClient = RunningService<RoleClient, InitializeRequestParam>;
type RmcpClient = RunningService<RoleClient, InitializeRequestParams>;

#[derive(Clone)]
pub struct ForgeMcpClient {
Expand Down Expand Up @@ -107,17 +107,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
Expand Down Expand Up @@ -219,23 +209,12 @@ impl ForgeMcpClient {
&self,
http: &McpHttpServer,
) -> anyhow::Result<RmcpClient> {
// 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.
Expand Down Expand Up @@ -366,12 +345,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::<Vec<_>>())
.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
Expand Down Expand Up @@ -526,16 +516,11 @@ impl ForgeMcpClient {

async fn call(&self, tool_name: &ToolName, input: &Value) -> anyhow::Result<ToolOutput> {
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<ToolOutput> = result
.content
Expand Down Expand Up @@ -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::<Vec<_>>())
.unwrap_or_default()
};
let stored = rmcp::transport::auth::StoredCredentials::new(
credentials.0,
credentials.1,
granted_scopes,
None,
);
save_store
.save(stored)
.await
Expand Down
9 changes: 9 additions & 0 deletions crates/forge_reqwest/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions crates/forge_reqwest/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<Item = Certificate> {
webpki_root_certs::TLS_SERVER_ROOT_CERTS
.iter()
.filter_map(|der| Certificate::from_der(der.as_ref()).ok())
}
3 changes: 2 additions & 1 deletion crates/forge_services/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/forge_services/src/tool_services/fetch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/forge_tracker/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/forge_tracker/src/collect/posthog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ pub struct Tracker {
impl Tracker {
pub fn new(api_secret: &'static str) -> Self {
// Configure HTTP client with connection pooling similar to forge_provider
let client = Client::builder()
let client = forge_reqwest::builder()
.connect_timeout(Duration::from_secs(10))
.read_timeout(Duration::from_secs(30))
.pool_idle_timeout(Duration::from_secs(90))
Expand Down
Loading