aether-mcp-servers 0.8.9

Built-in MCP tool servers for the Aether AI agent framework
Documentation
use mcp_servers::CodingMcp;
use mcp_servers::coding::error::BashError;
use mcp_servers::coding::tools::bash::{BashEnvironment, BashInput, execute_command};
use mcp_utils::testing::args;
use rmcp::model::{CallToolRequestParams, CallToolResponse, CancelTaskParams, TaskStatus};
use serde_json::json;
use std::fs::canonicalize;
use std::process::{Command, Stdio};

use super::common::{TestResult, await_terminal_task, call_task, cancels_prompts, connect, taskless};

#[tokio::test]
async fn bash_commands_receive_eof_instead_of_inherited_stdin() {
    const OPEN_STDIN: &str = "AETHER_TEST_BASH_OPEN_STDIN";

    if std::env::var_os(OPEN_STDIN).is_some() {
        let output = execute_command(
            BashInput { command: "read input || printf '%s' eof".into(), ..Default::default() },
            None,
            &BashEnvironment::default(),
        )
        .await
        .unwrap();
        assert_eq!(output.output, "eof");
        return;
    }

    let mut command = Command::new(std::env::current_exe().unwrap());
    command
        .args(["--exact", "bash_commands_receive_eof_instead_of_inherited_stdin", "--nocapture"])
        .env(OPEN_STDIN, "1")
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());
    let mut child = command.spawn().unwrap();
    let _open_stdin = child.stdin.take().unwrap();
    let output = child.wait_with_output().unwrap();

    assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr));
}

#[tokio::test]
async fn test_basic_command() {
    let output = execute_command(
        BashInput { command: "echo 'hello world'".into(), ..Default::default() },
        None,
        &BashEnvironment::default(),
    )
    .await
    .unwrap();
    assert_eq!(output.output.trim(), "hello world");
    assert_eq!(output.exit_code, 0);
    assert!(!output.killed);
}

#[tokio::test]
async fn test_command_with_exit_code_and_stderr() {
    let output = execute_command(
        BashInput { command: "echo error >&2; exit 42".into(), ..Default::default() },
        None,
        &BashEnvironment::default(),
    )
    .await
    .unwrap();
    assert!(output.output.contains("error"));
    assert_eq!(output.exit_code, 42);
    assert!(!output.killed);
}

#[tokio::test]
async fn test_command_timeout() {
    let output = execute_command(
        BashInput { command: "sleep 10".into(), timeout: Some(100), ..Default::default() },
        None,
        &BashEnvironment::default(),
    )
    .await
    .unwrap();
    assert!(output.output.contains("timed out"));
    assert_eq!(output.exit_code, -1);
    assert!(output.killed);
}

#[tokio::test]
async fn test_timeout_validation() {
    let result = execute_command(
        BashInput { command: "echo test".into(), timeout: Some(700_000), ..Default::default() },
        None,
        &BashEnvironment::default(),
    )
    .await;
    assert!(matches!(result.unwrap_err(), BashError::TimeoutTooLarge));
}

#[tokio::test]
async fn test_rm_command_blocked() {
    let result =
        execute_command(BashInput { command: "rm".into(), ..Default::default() }, None, &BashEnvironment::default())
            .await;
    assert!(matches!(result.unwrap_err(), BashError::Forbidden(_)));
}

#[tokio::test]
async fn command_runs_in_working_directory() -> Result<(), Box<dyn std::error::Error>> {
    let temp = tempfile::tempdir()?;
    let output = execute_command(
        BashInput { command: "pwd".into(), ..Default::default() },
        Some(temp.path()),
        &BashEnvironment::default(),
    )
    .await?;
    assert_eq!(canonicalize(output.output.trim())?, canonicalize(temp.path())?);
    Ok(())
}

#[tokio::test]
async fn background_bash_uses_tasks_and_returns_structured_output() -> TestResult {
    let client = connect(CodingMcp::new(std::env::temp_dir()), cancels_prompts()).await;
    let result = call_task(
        &client,
        "bash",
        json!({ "command": "echo stdout; echo stderr >&2; exit 7", "runInBackground": true }),
    )
    .await?;

    assert_eq!(result.is_error, Some(false));
    let structured = result.structured_content.as_ref().unwrap();
    let output = structured["output"].as_str().unwrap();
    assert!(output.contains("stdout"));
    assert!(output.contains("stderr"));
    assert_eq!(structured["exitCode"], 7);
    Ok(())
}

#[tokio::test]
async fn cancelling_a_background_bash_task_stops_it() -> TestResult {
    let client = connect(CodingMcp::new(std::env::temp_dir()), cancels_prompts()).await;
    let request = CallToolRequestParams::new("bash")
        .with_arguments(args(json!({ "command": "sleep 60", "runInBackground": true })));
    let CallToolResponse::Task(created) = client.peer().call_tool_once(request).await? else {
        panic!("background bash creates a task");
    };

    client.peer().cancel_task(CancelTaskParams::new(created.task.task_id.clone())).await?;

    let task = await_terminal_task(&client, &created.task.task_id).await?;
    assert_eq!(task.status(), TaskStatus::Cancelled);
    Ok(())
}

#[tokio::test]
async fn background_bash_requires_tasks_capability() -> TestResult {
    let client = connect(CodingMcp::new(std::env::temp_dir()), taskless()).await;
    let error = client
        .peer()
        .call_tool_once(
            CallToolRequestParams::new("bash")
                .with_arguments(args(json!({ "command": "echo never", "runInBackground": true }))),
        )
        .await
        .unwrap_err();
    assert!(
        error.to_string().to_lowercase().contains("missing required client capability"),
        "unexpected error: {error}"
    );
    Ok(())
}