aether-mcp-servers 0.4.21

Built-in MCP tool servers for the Aether AI agent framework
Documentation
mod common;

use common::{TestClient, TestResult, test_error};
use mcp_servers::coding::CodingMcp;
use mcp_servers::coding::tools::ast_grep::AstGrepInput;
use mcp_servers::coding::tools::bash::BashInput;
use mcp_servers::coding::tools::edit_file::EditFileArgs;
use mcp_servers::coding::tools::find::FindInput;
use mcp_servers::coding::tools::grep::GrepInput;
use mcp_servers::coding::tools::list_files::ListFilesArgs;
use mcp_servers::coding::tools::read_file::ReadFileArgs;
use mcp_servers::coding::tools::write_file::WriteFileArgs;
use mcp_servers::file_ops::FileEdit;
use std::fs::{self, canonicalize};

#[tokio::test]
async fn test_read_file_tool() -> TestResult {
    let mcp = TestClient::start(CodingMcp::new).await?;
    let test_content = "Hello, World!\nThis is a test file.";
    tokio::fs::write("/tmp/test_read_file.txt", test_content).await?;

    let parsed = mcp
        .call("read_file", ReadFileArgs { file_path: "/tmp/test_read_file.txt".to_string(), ..Default::default() })
        .await?;

    assert_eq!(parsed["status"], "success");
    assert_eq!(parsed["content"], "    1\tHello, World!\n    2\tThis is a test file.");
    assert_eq!(parsed["totalLines"], 2);
    assert_eq!(parsed["linesShown"], 2);

    let _ = tokio::fs::remove_file("/tmp/test_read_file.txt").await;
    Ok(())
}

#[tokio::test]
async fn test_write_file_tool() -> TestResult {
    let mcp = TestClient::start(CodingMcp::new).await?;
    let test_content = "This is test content written by the tool.";
    let test_path = "/tmp/test_write_file.txt";

    let parsed = mcp
        .call("write_file", WriteFileArgs { file_path: test_path.to_string(), content: test_content.to_string() })
        .await?;

    assert!(parsed["message"].as_str().unwrap().contains("Successfully wrote"));
    assert_eq!(parsed["bytesWritten"], test_content.len());
    assert_eq!(parsed["filePath"], test_path);

    let file_content = tokio::fs::read_to_string(test_path).await?;
    assert_eq!(file_content, test_content);

    let _ = tokio::fs::remove_file(test_path).await;
    Ok(())
}

#[tokio::test]
async fn test_bash_tool() -> TestResult {
    let mcp = TestClient::start(CodingMcp::new).await?;

    let parsed =
        mcp.call("bash", BashInput { command: "echo 'Hello from bash'".to_string(), ..Default::default() }).await?;

    assert_eq!(parsed["exitCode"], 0);
    assert!(parsed["output"].as_str().unwrap().contains("Hello from bash"));
    assert_eq!(parsed["killed"], false);
    Ok(())
}

#[tokio::test]
async fn test_edit_file_tool() -> TestResult {
    let mcp = TestClient::start(CodingMcp::new).await?;
    let test_path = "/tmp/test_edit_file.txt";
    let initial_content = "Hello, World!\nThis is a test.";
    tokio::fs::write(test_path, initial_content).await?;

    mcp.call("read_file", ReadFileArgs { file_path: test_path.to_string(), ..Default::default() }).await?;
    let parsed = mcp
        .call(
            "edit_file",
            EditFileArgs { file_path: test_path.to_string(), edits: vec![FileEdit::new("World", "Rust")] },
        )
        .await?;

    assert_eq!(parsed["status"], "success");
    assert_eq!(parsed["replacementsMade"], 1);
    let file_content = tokio::fs::read_to_string(test_path).await?;
    assert_eq!(file_content, "Hello, Rust!\nThis is a test.");

    tokio::fs::write(test_path, "test test test").await?;
    mcp.call("read_file", ReadFileArgs { file_path: test_path.to_string(), ..Default::default() }).await?;
    let parsed = mcp
        .call(
            "edit_file",
            EditFileArgs {
                file_path: test_path.to_string(),
                edits: vec![FileEdit {
                    old_string: "test".to_string(),
                    new_string: "TEST".to_string(),
                    replace_all: true,
                }],
            },
        )
        .await?;

    assert_eq!(parsed["replacementsMade"], 3);
    let file_content = tokio::fs::read_to_string(test_path).await?;
    assert_eq!(file_content, "TEST TEST TEST");

    let _ = tokio::fs::remove_file(test_path).await;
    Ok(())
}

#[tokio::test]
async fn test_list_files_tool() -> TestResult {
    let mcp = TestClient::start(CodingMcp::new).await?;
    let test_dir = "/tmp/test_list_files";
    let _ = fs::remove_dir_all(test_dir);
    fs::create_dir_all(test_dir)?;
    fs::write(format!("{test_dir}/file1.txt"), "content1")?;
    fs::write(format!("{test_dir}/file2.rs"), "fn main() {}")?;
    fs::create_dir(format!("{test_dir}/subdir"))?;
    fs::write(format!("{test_dir}/.hidden_file"), "hidden content")?;

    let parsed =
        mcp.call("list_files", ListFilesArgs { path: Some(test_dir.to_string()), ..Default::default() }).await?;

    assert_eq!(parsed["status"], "success");
    assert_eq!(parsed["totalCount"], 3);
    let files = parsed["files"].as_array().ok_or_else(|| test_error("Files should be an array"))?;
    let file_names: Vec<String> = files.iter().map(|f| f["name"].as_str().unwrap().to_string()).collect();
    assert!(file_names.contains(&"file1.txt".to_string()));
    assert!(file_names.contains(&"file2.rs".to_string()));
    assert!(file_names.contains(&"subdir".to_string()));
    assert!(!file_names.contains(&".hidden_file".to_string()));

    let parsed =
        mcp.call("list_files", ListFilesArgs { path: Some(test_dir.to_string()), include_hidden: Some(true) }).await?;
    assert_eq!(parsed["totalCount"], 4);

    let _ = fs::remove_dir_all(test_dir);
    Ok(())
}

#[tokio::test]
async fn test_list_files_uses_workspace_root_when_no_path_given() -> TestResult {
    let temp = tempfile::tempdir()?;
    let workspace = temp.path().to_path_buf();
    fs::write(temp.path().join("alpha.txt"), "a")?;
    fs::write(temp.path().join("beta.rs"), "b")?;

    let mcp = TestClient::start(|| CodingMcp::new().with_root_dir(workspace)).await?;
    let parsed = mcp.call("list_files", ListFilesArgs::default()).await?;
    let files = parsed["files"].as_array().ok_or_else(|| test_error("Files should be an array"))?;
    let file_names: Vec<String> = files.iter().map(|f| f["name"].as_str().unwrap().to_string()).collect();

    assert!(file_names.contains(&"alpha.txt".to_string()));
    assert!(file_names.contains(&"beta.rs".to_string()));
    Ok(())
}

#[tokio::test]
async fn test_bash_pwd_uses_workspace_root() -> TestResult {
    let temp = tempfile::tempdir()?;
    let workspace = temp.path().to_path_buf();
    let mcp = TestClient::start(|| CodingMcp::new().with_root_dir(workspace.clone())).await?;

    let parsed = mcp.call("bash", BashInput { command: "pwd".to_string(), ..Default::default() }).await?;
    let pwd = parsed["output"].as_str().ok_or_else(|| test_error("Expected output string"))?.trim();
    assert_eq!(canonicalize(pwd)?, canonicalize(&workspace)?);
    Ok(())
}

#[tokio::test]
async fn test_grep_and_find_use_workspace_root_when_no_path_given() -> TestResult {
    let temp = tempfile::tempdir()?;
    let workspace = temp.path().to_path_buf();
    fs::write(temp.path().join("root_only.txt"), "needle\n")?;
    fs::write(temp.path().join("other.rs"), "fn main() {}\n")?;

    let mcp = TestClient::start(|| CodingMcp::new().with_root_dir(workspace.clone())).await?;
    let parsed = mcp.call("grep", GrepInput { pattern: "needle".to_string(), ..Default::default() }).await?;
    let matches = parsed["matches"].as_array().ok_or_else(|| test_error("matches should be an array"))?;
    assert!(matches.iter().any(|m| m["file"].as_str().unwrap().ends_with("root_only.txt")));

    let parsed = mcp.call("find", FindInput { pattern: "*.rs".to_string(), ..Default::default() }).await?;
    assert_eq!(parsed["searchPath"].as_str().unwrap(), workspace.to_str().unwrap());
    let matches = parsed["matches"].as_array().ok_or_else(|| test_error("matches should be an array"))?;
    assert!(matches.iter().any(|m| m.as_str().unwrap().ends_with("other.rs")));
    Ok(())
}

#[tokio::test]
async fn test_ast_grep_uses_workspace_root_when_no_path_given() -> TestResult {
    let temp = tempfile::tempdir()?;
    let workspace = temp.path().to_path_buf();
    fs::write(workspace.join("lib.rs"), "fn target() {}\nfn other() {}\n")?;

    let mcp = TestClient::start(|| CodingMcp::new().with_root_dir(workspace.clone())).await?;
    let parsed = mcp
        .call(
            "ast_grep",
            AstGrepInput { language: "rs".to_string(), pattern: "fn $NAME() {}".to_string(), ..Default::default() },
        )
        .await?;
    let matches = parsed["matches"].as_array().ok_or_else(|| test_error("matches should be an array"))?;
    assert_eq!(parsed["searchPath"].as_str().unwrap(), workspace.to_str().unwrap());
    assert!(matches.iter().any(|m| {
        m["file"].as_str().unwrap().ends_with("lib.rs")
            && m["text"].as_str().unwrap() == "fn target() {}"
            && m["range"]["startLine"].as_u64().unwrap() == 1
            && m["captures"].as_array().unwrap().iter().any(|c| c["name"] == "NAME" && c["text"] == "target")
    }));

    Ok(())
}

#[cfg(feature = "test-helpers")]
#[tokio::test]
async fn test_read_before_edit_safety_with_relative_path_normalization() -> TestResult {
    let temp = tempfile::tempdir()?;
    let workspace = temp.path().to_path_buf();
    let test_file = temp.path().join("test.txt");
    fs::write(&test_file, "hello world")?;

    let server = CodingMcp::new().with_root_dir(workspace);

    server.test_read_file(ReadFileArgs { file_path: "test.txt".to_string(), ..Default::default() }).await?;

    let result = server
        .test_edit_file(EditFileArgs {
            file_path: "test.txt".to_string(),
            edits: vec![FileEdit::new("hello", "goodbye")],
        })
        .await;

    assert!(result.is_ok());
    Ok(())
}