Skip to content

Commit 24ac854

Browse files
amitksingh1490Amitautofix-ci[bot]laststylebender14
authored
refactor(mcp-client): reuse http client to prevent file descriptor leaks (#3025)
Co-authored-by: Amit <amit@Tailcalls-MacBook-Pro.local> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: laststylebender <43403528+laststylebender14@users.noreply.github.com>
1 parent 343bb2c commit 24ac854

File tree

1 file changed

+50
-14
lines changed

1 file changed

+50
-14
lines changed

crates/forge_infra/src/mcp_client.rs

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ use forge_app::McpClientInfra;
99
use forge_domain::{
1010
Environment, Image, McpHttpServer, McpServerConfig, ToolDefinition, ToolName, ToolOutput,
1111
};
12-
use http::{HeaderName, HeaderValue, header};
12+
use reqwest::Client;
13+
use reqwest::header::{HeaderName, HeaderValue};
1314
use rmcp::model::{CallToolRequestParam, ClientInfo, Implementation, InitializeRequestParam};
1415
use rmcp::service::RunningService;
1516
use rmcp::transport::sse_client::SseClientConfig;
@@ -33,20 +34,57 @@ type RmcpClient = RunningService<RoleClient, InitializeRequestParam>;
3334
#[derive(Clone)]
3435
pub struct ForgeMcpClient {
3536
client: Arc<RwLock<Option<Arc<RmcpClient>>>>,
37+
http_client: Arc<Client>,
3638
config: McpServerConfig,
3739
env_vars: BTreeMap<String, String>,
3840
environment: Environment,
3941
resolved_config: Arc<OnceLock<anyhow::Result<McpServerConfig>>>,
4042
}
4143

4244
impl ForgeMcpClient {
45+
/// Build a reqwest client with default headers from the MCP server config.
46+
fn build_http_client(http: &McpHttpServer) -> anyhow::Result<Client> {
47+
let mut header_map = reqwest::header::HeaderMap::new();
48+
for (key, value) in &http.headers {
49+
if let Ok(name) = HeaderName::from_str(key)
50+
&& let Ok(val) = HeaderValue::from_str(value)
51+
{
52+
header_map.insert(name, val);
53+
}
54+
}
55+
56+
Ok(Client::builder().default_headers(header_map).build()?)
57+
}
58+
4359
pub fn new(
4460
config: McpServerConfig,
4561
env_vars: &BTreeMap<String, String>,
4662
environment: Environment,
4763
) -> Self {
64+
// Try to resolve config early so we can extract headers for the HTTP client.
65+
// If resolution fails, fall back to a plain client (headers will be missing
66+
// but the error will surface when create_connection is called).
67+
let resolved = resolve_http_templates(
68+
match &config {
69+
McpServerConfig::Http(http) => http.clone(),
70+
McpServerConfig::Stdio(_) => McpHttpServer {
71+
url: String::new(),
72+
headers: BTreeMap::new(),
73+
timeout: None,
74+
disable: false,
75+
oauth: forge_domain::McpOAuthSetting::default(),
76+
},
77+
},
78+
env_vars,
79+
);
80+
81+
let http_client = resolved
82+
.and_then(|http| Self::build_http_client(&http))
83+
.unwrap_or_default();
84+
4885
Self {
4986
client: Default::default(),
87+
http_client: Arc::new(http_client),
5088
config,
5189
env_vars: env_vars.clone(),
5290
environment,
@@ -181,16 +219,16 @@ impl ForgeMcpClient {
181219
http: &McpHttpServer,
182220
) -> anyhow::Result<RmcpClient> {
183221
// Try HTTP first, fall back to SSE if it fails
184-
let client = self.reqwest_client(http)?;
222+
let client = self.reqwest_client();
185223
let transport = StreamableHttpClientTransport::with_client(
186-
client.clone(),
224+
client.as_ref().clone(),
187225
StreamableHttpClientTransportConfig::with_uri(http.url.clone()),
188226
);
189227
match self.client_info().serve(transport).await {
190228
Ok(client) => Ok(client),
191229
Err(_e) => {
192230
let transport = SseClientTransport::start_with_client(
193-
client,
231+
client.as_ref().clone(),
194232
SseClientConfig { sse_endpoint: http.url.clone().into(), ..Default::default() },
195233
)
196234
.await?;
@@ -358,9 +396,9 @@ impl ForgeMcpClient {
358396
http: &McpHttpServer,
359397
token: &str,
360398
) -> anyhow::Result<Arc<RmcpClient>> {
361-
let client = self.reqwest_client(http)?;
399+
let client = self.reqwest_client();
362400
let transport = StreamableHttpClientTransport::with_client(
363-
client,
401+
client.as_ref().clone(),
364402
StreamableHttpClientTransportConfig::with_uri(http.url.clone()).auth_header(token),
365403
);
366404

@@ -456,14 +494,12 @@ impl ForgeMcpClient {
456494
Ok((code, state))
457495
}
458496

459-
fn reqwest_client(&self, config: &McpHttpServer) -> anyhow::Result<reqwest::Client> {
460-
let mut headers = header::HeaderMap::new();
461-
for (key, value) in config.headers.iter() {
462-
headers.insert(HeaderName::from_str(key)?, HeaderValue::from_str(value)?);
463-
}
464-
465-
let client = reqwest::Client::builder().default_headers(headers);
466-
Ok(client.build()?)
497+
fn reqwest_client(&self) -> Arc<Client> {
498+
// Reuse the cached HTTP client (with pre-configured default headers)
499+
// to prevent file descriptor leaks. Each reqwest::Client manages its
500+
// own connection pool, so creating new clients for each connection
501+
// leads to "Too many open files" errors.
502+
self.http_client.clone()
467503
}
468504

469505
async fn list(&self) -> anyhow::Result<Vec<ToolDefinition>> {

0 commit comments

Comments
 (0)