mcp/developer: Refactor to use tokio SplitStream (#3894)

Signed-off-by: Colin Walters <walters@verbum.org>
This commit is contained in:
Colin Walters
2025-08-07 21:44:14 -04:00
committed by GitHub
parent e124bb084f
commit 719b569681
3 changed files with 74 additions and 69 deletions
Generated
+1
View File
@@ -3466,6 +3466,7 @@ dependencies = [
"tempfile",
"thiserror 1.0.69",
"tokio",
"tokio-stream",
"tracing",
"tracing-appender",
"tracing-subscriber",
+1
View File
@@ -16,6 +16,7 @@ mcp-server = { path = "../mcp-server" }
rmcp = { workspace = true }
anyhow = "1.0.94"
tokio = { version = "1", features = ["full"] }
tokio-stream = { version = "0.1", features = ["io-util"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-appender = "0.2"
+72 -69
View File
@@ -21,6 +21,7 @@ use tokio::{
process::Command,
sync::mpsc,
};
use tokio_stream::{wrappers::SplitStream, StreamExt as _};
use url::Url;
use include_dir::{include_dir, Dir};
@@ -641,83 +642,44 @@ impl DeveloperRouter {
.spawn()
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
let stdout = child.stdout.take().unwrap();
let stderr = child.stderr.take().unwrap();
let mut stdout_reader = BufReader::new(stdout);
let mut stderr_reader = BufReader::new(stderr);
let stdout = BufReader::new(child.stdout.take().unwrap());
let stderr = BufReader::new(child.stderr.take().unwrap());
let output_task = tokio::spawn(async move {
let mut combined_output = String::new();
let mut stdout_buf = Vec::new();
let mut stderr_buf = Vec::new();
// We have the individual two streams above, now merge them into one unified stream of
// an enum. ref https://blog.yoshuawuyts.com/futures-concurrency-3
let stdout = SplitStream::new(stdout.split(b'\n')).map(|v| ("stdout", v));
let stderr = SplitStream::new(stderr.split(b'\n')).map(|v| ("stderr", v));
let mut merged = stdout.merge(stderr);
let mut stdout_done = false;
let mut stderr_done = false;
while let Some((key, line)) = merged.next().await {
let mut line = line?;
// Re-add this as clients expect it
line.push(b'\n');
// Here we always convert to UTF-8 so agents don't have to deal with corrupted output
let line = String::from_utf8_lossy(&line);
loop {
tokio::select! {
n = stdout_reader.read_until(b'\n', &mut stdout_buf), if !stdout_done => {
if n? == 0 {
stdout_done = true;
} else {
let line = String::from_utf8_lossy(&stdout_buf);
combined_output.push_str(&line);
notifier.try_send(JsonRpcMessage::Notification(JsonRpcNotification {
jsonrpc: JsonRpcVersion2_0,
notification: Notification {
method: "notifications/message".to_string(),
params: object!({
"level": "info",
"data": {
"type": "shell",
"stream": "stdout",
"output": line.to_string(),
}
}),
extensions: Default::default(),
notifier
.try_send(JsonRpcMessage::Notification(JsonRpcNotification {
jsonrpc: JsonRpcVersion2_0,
notification: Notification {
method: "notifications/message".to_string(),
params: object!({
"level": "info",
"data": {
"type": "shell",
"stream": key,
"output": line,
}
})).ok();
combined_output.push_str(&line);
stdout_buf.clear();
}
}
n = stderr_reader.read_until(b'\n', &mut stderr_buf), if !stderr_done => {
if n? == 0 {
stderr_done = true;
} else {
let line = String::from_utf8_lossy(&stderr_buf);
notifier.try_send(JsonRpcMessage::Notification(JsonRpcNotification {
jsonrpc: JsonRpcVersion2_0,
notification: Notification {
method: "notifications/message".to_string(),
params: object!({
"level": "info",
"data": {
"type": "shell",
"stream": "stderr",
"output": line.to_string(),
}
}),
extensions: Default::default(),
}
})).ok();
combined_output.push_str(&line);
stderr_buf.clear();
}
}
else => break,
}
if stdout_done && stderr_done {
break;
}
}),
extensions: Default::default(),
},
}))
.ok();
}
Ok::<_, std::io::Error>(combined_output)
});
@@ -3472,4 +3434,45 @@ mod tests {
assert_eq!(result.0, "");
assert_eq!(result.1, "");
}
#[tokio::test]
#[serial]
async fn test_shell_output_without_trailing_newline() {
let temp_dir = tempfile::tempdir().unwrap();
std::env::set_current_dir(&temp_dir).unwrap();
let router = get_router().await;
// Test command that outputs content without a trailing newline
let command = if cfg!(windows) {
"echo|set /p=\"Content without newline\""
} else {
"printf 'Content without newline'"
};
let result = router
.call_tool("shell", json!({ "command": command }), dummy_sender())
.await
.unwrap();
// Find the assistant content (which contains the full output)
let assistant_content = result
.iter()
.find(|c| {
c.audience()
.is_some_and(|roles| roles.contains(&Role::Assistant))
})
.unwrap()
.as_text()
.unwrap();
// The output should contain the content even without a trailing newline
assert!(
assistant_content.text.contains("Content without newline"),
"Output should contain content even without trailing newline, but got: {}",
assistant_content.text
);
temp_dir.close().unwrap();
}
}