Skip to content

Commit b6821ab

Browse files
fix(mcp): drain stderr from child processes to prevent buffer overflow (#2598)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 82f1f6f commit b6821ab

2 files changed

Lines changed: 136 additions & 3 deletions

File tree

crates/forge_domain/src/mcp.rs

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,19 @@ impl McpServerConfig {
3333
command: command.into(),
3434
args,
3535
env: env.unwrap_or_default(),
36+
timeout: None,
3637
disable: false,
3738
})
3839
}
3940

4041
/// Create a new HTTP-based MCP server (auto-detects transport type)
4142
pub fn new_http(url: impl Into<String>) -> Self {
42-
Self::Http(McpHttpServer { url: url.into(), headers: BTreeMap::new(), disable: false })
43+
Self::Http(McpHttpServer {
44+
url: url.into(),
45+
headers: BTreeMap::new(),
46+
timeout: None,
47+
disable: false,
48+
})
4349
}
4450

4551
pub fn is_disabled(&self) -> bool {
@@ -73,6 +79,11 @@ pub struct McpStdioServer {
7379
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
7480
pub env: BTreeMap<String, String>,
7581

82+
/// Timeout in seconds for tool calls to this MCP server
83+
/// If not specified, uses the default FORGE_MCP_TIMEOUT or 300 seconds
84+
#[serde(default, skip_serializing_if = "Option::is_none")]
85+
pub timeout: Option<u64>,
86+
7687
/// Disable it temporarily without having to
7788
/// remove it from the config.
7889
#[serde(default)]
@@ -90,6 +101,11 @@ pub struct McpHttpServer {
90101
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
91102
pub headers: BTreeMap<String, String>,
92103

104+
/// Timeout in seconds for HTTP requests to this MCP server
105+
/// If not specified, uses the default FORGE_MCP_TIMEOUT or 300 seconds
106+
#[serde(default, skip_serializing_if = "Option::is_none")]
107+
pub timeout: Option<u64>,
108+
93109
/// Disable it temporarily without having to
94110
/// remove it from the config.
95111
#[serde(default)]
@@ -337,6 +353,53 @@ mod tests {
337353
}
338354
}
339355

356+
#[test]
357+
fn test_http_server_with_timeout() {
358+
use pretty_assertions::assert_eq;
359+
360+
let json = r#"{
361+
"mcpServers": {
362+
"slow-server": {
363+
"url": "https://api.example.com/mcp/",
364+
"timeout": 600
365+
}
366+
}
367+
}"#;
368+
369+
let actual: McpConfig = serde_json::from_str(json).unwrap();
370+
371+
match actual.mcp_servers.get(&"slow-server".to_string().into()) {
372+
Some(McpServerConfig::Http(server)) => {
373+
assert_eq!(server.url, "https://api.example.com/mcp/");
374+
assert_eq!(server.timeout, Some(600));
375+
}
376+
_ => panic!("Expected Http variant"),
377+
}
378+
}
379+
380+
#[test]
381+
fn test_http_server_without_timeout() {
382+
use pretty_assertions::assert_eq;
383+
384+
let json = r#"{
385+
"mcpServers": {
386+
"fast-server": {
387+
"url": "https://api.example.com/mcp/"
388+
}
389+
}
390+
}"#;
391+
392+
let actual: McpConfig = serde_json::from_str(json).unwrap();
393+
394+
match actual.mcp_servers.get(&"fast-server".to_string().into()) {
395+
Some(McpServerConfig::Http(server)) => {
396+
assert_eq!(server.url, "https://api.example.com/mcp/");
397+
assert_eq!(server.timeout, None);
398+
}
399+
_ => panic!("Expected Http variant"),
400+
}
401+
}
402+
340403
#[test]
341404
fn test_server_type() {
342405
use fake::{Fake, Faker};
@@ -354,4 +417,60 @@ mod tests {
354417
let expected = "HTTP";
355418
assert_eq!(actual, expected);
356419
}
420+
421+
#[test]
422+
fn test_stdio_server_with_timeout() {
423+
use pretty_assertions::assert_eq;
424+
425+
let json = r#"{
426+
"mcpServers": {
427+
"slow-stdio-server": {
428+
"command": "node",
429+
"args": ["server.js"],
430+
"timeout": 600
431+
}
432+
}
433+
}"#;
434+
435+
let actual: McpConfig = serde_json::from_str(json).unwrap();
436+
437+
match actual
438+
.mcp_servers
439+
.get(&"slow-stdio-server".to_string().into())
440+
{
441+
Some(McpServerConfig::Stdio(server)) => {
442+
assert_eq!(server.command, "node");
443+
assert_eq!(server.args, vec!["server.js"]);
444+
assert_eq!(server.timeout, Some(600));
445+
}
446+
_ => panic!("Expected Stdio variant"),
447+
}
448+
}
449+
450+
#[test]
451+
fn test_stdio_server_without_timeout() {
452+
use pretty_assertions::assert_eq;
453+
454+
let json = r#"{
455+
"mcpServers": {
456+
"fast-stdio-server": {
457+
"command": "node",
458+
"args": ["server.js"]
459+
}
460+
}
461+
}"#;
462+
463+
let actual: McpConfig = serde_json::from_str(json).unwrap();
464+
465+
match actual
466+
.mcp_servers
467+
.get(&"fast-stdio-server".to_string().into())
468+
{
469+
Some(McpServerConfig::Stdio(server)) => {
470+
assert_eq!(server.command, "node");
471+
assert_eq!(server.timeout, None);
472+
}
473+
_ => panic!("Expected Stdio variant"),
474+
}
475+
}
357476
}

crates/forge_infra/src/mcp_client.rs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ use rmcp::transport::{SseClientTransport, StreamableHttpClientTransport, TokioCh
1616
use rmcp::{RoleClient, ServiceExt};
1717
use schemars::Schema;
1818
use serde_json::Value;
19+
use tokio::io::{AsyncBufReadExt, BufReader};
1920
use tokio::process::Command;
2021

2122
use crate::error::Error;
@@ -106,11 +107,21 @@ impl ForgeMcpClient {
106107

107108
cmd.args(&stdio.args).kill_on_drop(true);
108109

109-
// Use builder pattern to capture and ignore stderr to silence MCP logs
110-
let (transport, _stderr) = TokioChildProcess::builder(cmd)
110+
// Use builder pattern to capture stderr
111+
let (transport, stderr) = TokioChildProcess::builder(cmd)
111112
.stderr(std::process::Stdio::piped())
112113
.spawn()?;
113114

115+
// Spawn a task to drain stderr to prevent buffer overflow
116+
// If stderr fills up, the child process will block
117+
if let Some(stderr) = stderr {
118+
tokio::spawn(async move {
119+
let mut reader = BufReader::new(stderr).lines();
120+
while let Ok(Some(line)) = reader.next_line().await {
121+
tracing::warn!("MCP server stderr: {}", line);
122+
}
123+
});
124+
}
114125
self.client_info().serve(transport).await?
115126
}
116127
McpServerConfig::Http(http) => {
@@ -297,6 +308,7 @@ mod tests {
297308
("X-API-Key".to_string(), "{{env.API_KEY}}".to_string()),
298309
("Content-Type".to_string(), "application/json".to_string()),
299310
]),
311+
timeout: None,
300312
disable: false,
301313
};
302314

@@ -326,6 +338,7 @@ mod tests {
326338
"Authorization".to_string(),
327339
"Bearer {{env.MISSING_VAR}}".to_string(),
328340
)]),
341+
timeout: None,
329342
disable: false,
330343
};
331344

@@ -345,6 +358,7 @@ mod tests {
345358
let http = McpHttpServer {
346359
url: "https://test.example.com".to_string(),
347360
headers: BTreeMap::from([("Auth".to_string(), "{{env.TOKEN}}".to_string())]),
361+
timeout: None,
348362
disable: true,
349363
};
350364

0 commit comments

Comments
 (0)