use std::path::Path;
use std::process::Command as StdCommand;
use rmcp::{
ServiceError, ServiceExt, model::CallToolRequestParams, model::ErrorCode,
transport::TokioChildProcess,
};
use serde_json::{Value, json};
use tokio::process::Command;
fn server_bin() -> String {
std::env::var("CARGO_BIN_EXE_lds")
.unwrap_or_else(|_| format!("{}/target/debug/lds", env!("CARGO_MANIFEST_DIR")))
}
async fn connect() -> rmcp::service::RunningService<rmcp::RoleClient, ()> {
let cmd = Command::new(server_bin());
let transport = TokioChildProcess::new(cmd).expect("failed to spawn lds server");
().serve(transport)
.await
.expect("failed to initialize MCP client")
}
async fn connect_in(dir: &std::path::Path) -> rmcp::service::RunningService<rmcp::RoleClient, ()> {
let mut cmd = Command::new(server_bin());
cmd.current_dir(dir);
let transport = TokioChildProcess::new(cmd).expect("failed to spawn lds server");
().serve(transport)
.await
.expect("failed to initialize MCP client")
}
fn call_params(name: &str, args: Value) -> CallToolRequestParams {
let req = CallToolRequestParams::new(name.to_string());
match args {
Value::Object(map) => req.with_arguments(map),
_ => req,
}
}
fn extract_text(result: &rmcp::model::CallToolResult) -> String {
result
.content
.iter()
.filter_map(|c| match &c.raw {
rmcp::model::RawContent::Text(t) => Some(t.text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n")
}
struct TempRepo {
dir: tempfile::TempDir,
}
impl TempRepo {
fn new() -> Self {
let dir = tempfile::tempdir().expect("tempdir");
Self::run_git(dir.path(), &["init", "-b", "main"]);
Self::run_git(dir.path(), &["config", "user.email", "test@test.com"]);
Self::run_git(dir.path(), &["config", "user.name", "Test"]);
std::fs::write(dir.path().join("README.md"), "# test\n").unwrap();
Self::run_git(dir.path(), &["add", "."]);
Self::run_git(dir.path(), &["commit", "-m", "initial"]);
std::fs::create_dir_all(dir.path().join(".worktrees")).unwrap();
Self { dir }
}
fn run_git(dir: &Path, args: &[&str]) {
StdCommand::new("git")
.args(args)
.current_dir(dir)
.output()
.expect("git invocation");
}
fn path_str(&self) -> &str {
self.dir.path().to_str().unwrap()
}
}
#[tokio::test]
async fn list_tools_includes_static_surface() {
let client = connect().await;
let tools = client.peer().list_all_tools().await.unwrap();
let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
for expected in [
"session_start",
"session_info",
"git_status",
"git_log",
"git_diff",
"git_commit",
"git_worktree_add",
"git_worktree_remove",
"git_worktree_list",
"git_merge",
"git_branch_delete",
"recipe_list",
"recipe_run",
"recipe_logs",
"sandbox_write",
"sandbox_read",
"sandbox_edit",
"sandbox_append",
"sandbox_head",
"sandbox_tail",
"sandbox_rollback",
"sandbox_history",
"sandbox_python",
"sandbox_python_file",
"gh_run_view",
"gh_run_log_failed",
"mcp_call",
"mcp_route_list",
"mcp_route_register",
"mcp_route_remove",
] {
assert!(
names.contains(&expected),
"tool {expected:?} missing from list_tools; got {names:?}"
);
}
client.cancel().await.unwrap();
}
#[tokio::test]
async fn session_start_returns_id() {
let repo = TempRepo::new();
let client = connect().await;
let result = client
.peer()
.call_tool(call_params(
"session_start",
json!({ "root": repo.path_str() }),
))
.await
.unwrap();
let text = extract_text(&result);
assert!(text.contains("session_id"));
assert!(text.contains(repo.path_str()));
assert!(text.contains("\"is_default\": true"));
client.cancel().await.unwrap();
}
#[tokio::test]
async fn session_start_alias_round_trip() {
let repo = TempRepo::new();
let client = connect().await;
let start = client
.peer()
.call_tool(call_params(
"session_start",
json!({ "root": repo.path_str(), "alias": "main-worker" }),
))
.await
.unwrap();
let start_text = extract_text(&start);
assert!(
start_text.contains("\"alias\": \"main-worker\""),
"session_start must echo the alias, got: {start_text}"
);
let describe = client
.peer()
.call_tool(call_params(
"session_describe",
json!({ "key": "main-worker" }),
))
.await
.unwrap();
let describe_text = extract_text(&describe);
assert!(
describe_text.contains("\"alias\": \"main-worker\""),
"session_describe(key=alias) must resolve, got: {describe_text}"
);
assert!(describe_text.contains(repo.path_str()));
client.cancel().await.unwrap();
}
#[tokio::test]
async fn git_status_round_trip() {
let repo = TempRepo::new();
let client = connect().await;
client
.peer()
.call_tool(call_params(
"session_start",
json!({ "root": repo.path_str() }),
))
.await
.unwrap();
let result_clean = client
.peer()
.call_tool(call_params("git_status", json!({})))
.await
.unwrap();
let text_clean = extract_text(&result_clean);
let json_clean: Value =
serde_json::from_str(&text_clean).expect("git_status clean: payload must be JSON");
assert_eq!(json_clean["staged"], json!([]), "got: {text_clean}");
assert_eq!(json_clean["unstaged"], json!([]), "got: {text_clean}");
assert_eq!(json_clean["branch"], json!("main"), "got: {text_clean}");
std::fs::write(repo.dir.path().join("dirty.txt"), "content\n").unwrap();
let result_dirty = client
.peer()
.call_tool(call_params("git_status", json!({})))
.await
.unwrap();
let text_dirty = extract_text(&result_dirty);
let json_dirty: Value =
serde_json::from_str(&text_dirty).expect("git_status dirty: payload must be JSON");
let untracked = json_dirty["untracked"]
.as_array()
.expect("untracked must be an array");
assert!(
untracked.iter().any(|v| v.as_str() == Some("dirty.txt")),
"expected dirty.txt in untracked, got: {text_dirty}"
);
client.cancel().await.unwrap();
}
#[tokio::test]
async fn sandbox_write_read_round_trip() {
let repo = TempRepo::new();
let client = connect().await;
client
.peer()
.call_tool(call_params(
"session_start",
json!({ "root": repo.path_str() }),
))
.await
.unwrap();
let write_result = client
.peer()
.call_tool(call_params(
"sandbox_write",
json!({ "path": "note.txt", "content": "from e2e\nsecond line\n" }),
))
.await
.unwrap();
let write_text = extract_text(&write_result);
assert!(write_text.contains("\"path\": \"note.txt\""));
assert!(write_text.contains("bytes_written"));
let read_result = client
.peer()
.call_tool(call_params("sandbox_read", json!({ "path": "note.txt" })))
.await
.unwrap();
let read_text = extract_text(&read_result);
assert!(read_text.contains("from e2e"));
assert!(read_text.contains("second line"));
client.cancel().await.unwrap();
}
#[tokio::test]
async fn calling_tool_without_session_errors() {
let tmpdir = tempfile::tempdir().expect("tempdir");
let client = connect_in(tmpdir.path()).await;
let outcome = client
.peer()
.call_tool(call_params("git_status", json!({})))
.await;
assert!(outcome.is_err(), "expected error, got {outcome:?}");
client.cancel().await.unwrap();
}
#[tokio::test]
async fn calling_tool_auto_starts_in_project_root() {
let repo = TempRepo::new();
let client = connect_in(repo.dir.path()).await;
let outcome = client
.peer()
.call_tool(call_params("git_status", json!({})))
.await;
assert!(
outcome.is_ok(),
"expected auto-start to succeed, got {outcome:?}"
);
client.cancel().await.unwrap();
}
#[tokio::test]
async fn no_session_error_has_internal_error_code() {
let tmpdir = tempfile::tempdir().expect("tempdir");
let client = connect_in(tmpdir.path()).await;
let outcome = client
.peer()
.call_tool(call_params("git_status", json!({})))
.await;
match outcome {
Err(ServiceError::McpError(ref err_data)) => {
assert_eq!(
err_data.code,
ErrorCode::INTERNAL_ERROR,
"no-session error must use code -32603 (INTERNAL_ERROR), got {:?}",
err_data.code
);
}
Err(other) => panic!("expected McpError(-32603), got ServiceError variant: {other:?}"),
Ok(_) => panic!("expected error for no-session call, got Ok"),
}
client.cancel().await.unwrap();
}
#[tokio::test]
async fn mcp_call_self_loop_rejected() {
let repo = TempRepo::new();
let client = connect_in(repo.dir.path()).await;
let outcome = client
.peer()
.call_tool(call_params(
"mcp_call",
json!({ "uri": "lds://git_status", "args": {} }),
))
.await;
match outcome {
Err(ServiceError::McpError(ref err_data)) => {
assert!(
err_data.message.contains("self-loop"),
"expected self-loop error message, got: {}",
err_data.message
);
}
Ok(result) => {
let text = extract_text(&result);
assert!(
result.is_error == Some(true) && text.contains("self-loop"),
"expected self-loop error, got Ok: {text}"
);
}
Err(other) => panic!("unexpected error variant: {other:?}"),
}
client.cancel().await.unwrap();
}
#[tokio::test]
async fn mcp_call_proxies_to_child_lds_server() {
let repo_a = TempRepo::new();
let client_a = connect_in(repo_a.dir.path()).await;
let repo_b = TempRepo::new();
let register_result = client_a
.peer()
.call_tool(call_params(
"mcp_route_register",
json!({ "name": "child_lds", "command": server_bin() }),
))
.await
.unwrap();
assert!(
register_result.is_error != Some(true),
"mcp_route_register should succeed, got: {:?}",
extract_text(®ister_result)
);
let start_result = client_a
.peer()
.call_tool(call_params(
"mcp_call",
json!({
"uri": "child_lds://session_start",
"args": { "root": repo_b.path_str() },
}),
))
.await
.unwrap();
let start_text = extract_text(&start_result);
assert!(
start_result.is_error != Some(true) && start_text.contains("session_id"),
"proxied session_start on child_lds should succeed, got: {start_text}"
);
let status_result = client_a
.peer()
.call_tool(call_params(
"mcp_call",
json!({ "uri": "child_lds://git_status", "args": {} }),
))
.await
.unwrap();
let status_text = extract_text(&status_result);
assert!(
status_result.is_error != Some(true),
"proxied git_status on child_lds should succeed, got: {status_text}"
);
let status_json: Value = serde_json::from_str(&status_text)
.expect("proxied git_status payload must be transparent JSON");
assert_eq!(status_json["branch"], json!("main"), "got: {status_text}");
client_a.cancel().await.unwrap();
}
#[tokio::test]
async fn session_start_recovers_after_previous_root_deleted() {
let dir_a = tempfile::tempdir().expect("tempdir A");
std::fs::write(dir_a.path().join("justfile"), "default:\n\t@echo ok\n").unwrap();
let client = connect().await;
let result = client
.peer()
.call_tool(call_params(
"session_start",
json!({ "root": dir_a.path().to_str().unwrap() }),
))
.await
.unwrap();
let text = extract_text(&result);
assert!(
text.contains("session_id"),
"step 3: session_start(A) should succeed, got: {text}"
);
let result = client
.peer()
.call_tool(call_params("recipe_list", json!({})))
.await
.unwrap();
assert!(
result.is_error != Some(true),
"step 4: recipe_list after session_start(A) should succeed, got: {:?}",
extract_text(&result)
);
std::fs::remove_dir_all(dir_a.path()).unwrap();
let outcome = client
.peer()
.call_tool(call_params("recipe_list", json!({})))
.await;
match outcome {
Err(ServiceError::McpError(ref err_data)) => {
assert!(
err_data
.message
.contains("session root path no longer exists"),
"step 6: expected 'session root path no longer exists' in error message, got: {}",
err_data.message
);
}
Ok(result) => {
let text = extract_text(&result);
assert!(
result.is_error == Some(true)
&& text.contains("session root path no longer exists"),
"step 6: expected SessionRootGone error, got Ok: {text}"
);
}
Err(other) => panic!("step 6: unexpected error variant: {other:?}"),
}
let dir_b = tempfile::tempdir().expect("tempdir B");
std::fs::write(dir_b.path().join("justfile"), "default:\n\t@echo ok\n").unwrap();
let result = client
.peer()
.call_tool(call_params(
"session_start",
json!({ "root": dir_b.path().to_str().unwrap() }),
))
.await
.unwrap();
let text = extract_text(&result);
assert!(
text.contains("session_id"),
"step 8: session_start(B) must succeed after root A was deleted, got: {text}"
);
let result = client
.peer()
.call_tool(call_params("recipe_list", json!({})))
.await
.unwrap();
assert!(
result.is_error != Some(true),
"step 9: recipe_list after session_start(B) should succeed, got: {:?}",
extract_text(&result)
);
client.cancel().await.unwrap();
}