use anyhow::Result;
use serde_json::json;
use tempfile::TempDir;
use super::mcp_client::McpClient;
use crate::common::test_utils::{rust_analyzer_available, rust_workspace_path};
#[test]
#[ignore = "Requires mcpls binary built"]
fn test_e2e_initialize_handshake() -> Result<()> {
let mut client = McpClient::spawn()?;
let response = client.initialize()?;
assert!(
response.get("result").is_some(),
"Response should have 'result' field"
);
let result = &response["result"];
assert_eq!(
result["protocolVersion"], "2024-11-05",
"Protocol version should match"
);
assert!(
result["capabilities"]["tools"].is_object(),
"Should expose tools capability"
);
assert_eq!(
result["serverInfo"]["name"], "mcpls",
"Server name should be 'mcpls'"
);
Ok(())
}
#[test]
#[ignore = "Requires mcpls binary built"]
fn test_e2e_initialize_reflects_configured_mcp_title() -> Result<()> {
let config_path =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/mcp_config.toml");
let mut client = McpClient::spawn_with_args(&[
"--config",
config_path
.to_str()
.ok_or_else(|| anyhow::anyhow!("Invalid config path"))?,
])?;
let response = client.initialize()?;
let result = &response["result"];
assert_eq!(
result["serverInfo"]["title"], "E2E Custom Title",
"Configured mcp.title should surface in serverInfo.title"
);
assert_eq!(
result["serverInfo"]["name"], "mcpls",
"serverInfo.name stays hardcoded regardless of [mcp] config"
);
Ok(())
}
#[test]
#[ignore = "Requires mcpls binary built"]
fn test_e2e_tool_prefix_reaches_real_wiring() -> Result<()> {
let config_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/mcp_tool_prefix.toml");
let mut client = McpClient::spawn_with_args(&[
"--config",
config_path
.to_str()
.ok_or_else(|| anyhow::anyhow!("Invalid config path"))?,
])?;
client.initialize()?;
let list_response = client.list_tools()?;
let names: Vec<&str> = list_response["result"]["tools"]
.as_array()
.ok_or_else(|| anyhow::anyhow!("tools/list did not return an array"))?
.iter()
.filter_map(|tool| tool["name"].as_str())
.collect();
assert!(
names.contains(&"optics_get_cached_diagnostics"),
"tools/list should expose the configured prefix: {names:?}"
);
assert!(
!names.contains(&"get_cached_diagnostics"),
"tools/list should not expose the unprefixed name once a prefix is configured: {names:?}"
);
let prefixed_result = client.call_tool(
"optics_get_cached_diagnostics",
&json!({ "file_path": "/nonexistent/file.rs" }),
);
if let Err(e) = &prefixed_result {
assert!(
!e.to_string().contains("tool not found"),
"prefixed tool call should reach the handler: {e}"
);
}
let bare_result = client.call_tool(
"get_cached_diagnostics",
&json!({ "file_path": "/nonexistent/file.rs" }),
);
match bare_result {
Ok(value) => {
panic!("bare tool name should be rejected once a prefix is configured, got: {value:?}")
}
Err(err) => assert!(err.to_string().contains("tool not found"), "{err}"),
}
Ok(())
}
#[test]
#[ignore = "Requires mcpls binary built"]
fn test_e2e_list_tools() -> Result<()> {
let mut client = McpClient::spawn()?;
client.initialize()?;
let response = client.list_tools()?;
let tools = response["result"]["tools"]
.as_array()
.unwrap_or_else(|| panic!("tools should be an array"));
assert_eq!(tools.len(), 20, "Should have exactly 20 tools");
let tool_names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
for expected in &[
"get_hover",
"get_definition",
"get_references",
"get_diagnostics",
"rename_symbol",
"get_completions",
"get_document_symbols",
"format_document",
"workspace_symbol_search",
"get_code_actions",
"prepare_call_hierarchy",
"get_incoming_calls",
"get_outgoing_calls",
"get_cached_diagnostics",
"get_server_logs",
"get_server_messages",
"get_signature_help",
"go_to_implementation",
"go_to_type_definition",
"get_inlay_hints",
] {
assert!(tool_names.contains(expected), "Should have {expected} tool");
}
Ok(())
}
#[test]
#[ignore = "Requires mcpls binary built"]
fn test_e2e_tool_schemas() -> Result<()> {
let mut client = McpClient::spawn()?;
client.initialize()?;
let response = client.list_tools()?;
let tools = response["result"]["tools"]
.as_array()
.unwrap_or_else(|| panic!("tools should be an array"));
for tool in tools {
let tool_name = tool["name"]
.as_str()
.unwrap_or_else(|| panic!("Tool should have name field"));
assert!(
tool["name"].is_string(),
"Tool '{tool_name}' should have name as string"
);
assert!(
tool["description"].is_string(),
"Tool '{tool_name}' should have description as string"
);
assert!(
tool["inputSchema"].is_object(),
"Tool '{tool_name}' should have inputSchema as object"
);
let schema = &tool["inputSchema"];
assert_eq!(
schema["type"], "object",
"Tool '{tool_name}' schema type should be 'object'"
);
assert!(
schema["properties"].is_object(),
"Tool '{tool_name}' schema should have properties object"
);
}
Ok(())
}
#[test]
#[ignore = "Requires mcpls binary built"]
fn test_e2e_invalid_tool_call() -> Result<()> {
let mut client = McpClient::spawn()?;
client.initialize()?;
let result = client.call_tool("non_existent_tool", &json!({}));
assert!(result.is_err(), "Should return error for non-existent tool");
if let Err(err) = result {
let error_msg = format!("{err:?}");
assert!(
error_msg.contains("error") || error_msg.contains("Error"),
"Error message should indicate failure"
);
}
Ok(())
}
#[test]
#[ignore = "Requires mcpls binary built"]
fn test_e2e_tool_call_missing_params() -> Result<()> {
let mut client = McpClient::spawn()?;
client.initialize()?;
let result = client.call_tool("get_hover", &json!({}));
assert!(
result.is_err(),
"Should return error for missing required parameters"
);
if let Err(err) = result {
let error_msg = format!("{err:?}");
assert!(
error_msg.contains("error") || error_msg.contains("Error"),
"Error message should indicate parameter validation failure"
);
}
Ok(())
}
#[test]
#[ignore = "Requires mcpls binary built"]
fn test_e2e_tool_call_invalid_file() -> Result<()> {
let mut client = McpClient::spawn()?;
client.initialize()?;
let result = client.call_tool(
"get_hover",
&json!({
"file_path": "/nonexistent/path/to/file.rs",
"line": 1,
"character": 1
}),
);
assert!(result.is_err(), "Should return error for non-existent file");
Ok(())
}
#[test]
#[ignore = "Requires mcpls binary built"]
fn test_e2e_tool_call_invalid_position() -> Result<()> {
use std::fs;
let mut client = McpClient::spawn()?;
client.initialize()?;
let temp_dir = TempDir::new()?;
let test_file = temp_dir.path().join("test.rs");
fs::write(&test_file, "fn main() {}\n")?;
let result = client.call_tool(
"get_definition",
&json!({
"file_path": test_file.to_string_lossy(),
"line": 9999,
"character": 9999
}),
);
if let Ok(response) = result {
let result_field = &response["result"];
assert!(
result_field.is_null() || result_field.is_array() || result_field.is_object(),
"Should return null or empty result for invalid position"
);
}
Ok(())
}
#[test]
#[ignore = "Requires mcpls binary built"]
fn test_e2e_complete_workflow() -> Result<()> {
let mut client = McpClient::spawn()?;
let init_response = client.initialize()?;
assert!(init_response.get("result").is_some());
let list_response = client.list_tools()?;
let tools = list_response["result"]["tools"]
.as_array()
.unwrap_or_else(|| panic!("tools should be an array"));
assert!(!tools.is_empty(), "Should have tools available");
let _result = client.call_tool("get_diagnostics", &json!({"file_path": "test.rs"}));
Ok(())
}
#[test]
#[ignore = "Requires mcpls binary built"]
fn test_e2e_multiple_requests() -> Result<()> {
let mut client = McpClient::spawn()?;
let response1 = client.initialize()?;
assert!(response1.get("result").is_some());
let response2 = client.list_tools()?;
assert!(response2.get("result").is_some());
let response3 = client.list_tools()?;
assert!(response3.get("result").is_some());
assert_ne!(
response1.get("id"),
response2.get("id"),
"Different requests should have different IDs"
);
assert_ne!(
response2.get("id"),
response3.get("id"),
"Different requests should have different IDs"
);
Ok(())
}
#[test]
#[cfg(unix)]
#[ignore = "Requires mcpls binary built"]
fn test_e2e_sigterm_exits_promptly_while_client_stdin_open() -> Result<()> {
let mut client = McpClient::spawn()?;
client.initialize()?;
let pid = client.pid();
let status = std::process::Command::new("kill")
.args(["-TERM", &pid.to_string()])
.status()?;
assert!(
status.success(),
"failed to send SIGTERM to mcpls (pid {pid})"
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
if let Some(exit_status) = client.try_wait()? {
assert_eq!(
exit_status.code(),
Some(0),
"mcpls should exit with status 0 via its own shutdown path, not be killed \
by the default SIGTERM disposition (issue #308 regression)"
);
return Ok(());
}
assert!(
std::time::Instant::now() < deadline,
"mcpls did not exit within 5s of SIGTERM while the client's stdin write end \
was still open (issue #308 regression)"
);
std::thread::sleep(std::time::Duration::from_millis(50));
}
}
#[test]
#[cfg(unix)]
#[ignore = "Requires mcpls binary built"]
fn test_e2e_sigterm_exits_promptly_during_handshake_wait() -> Result<()> {
let mut client = McpClient::spawn()?;
std::thread::sleep(std::time::Duration::from_millis(50));
let pid = client.pid();
let status = std::process::Command::new("kill")
.args(["-TERM", &pid.to_string()])
.status()?;
assert!(
status.success(),
"failed to send SIGTERM to mcpls (pid {pid})"
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
if let Some(exit_status) = client.try_wait()? {
assert_eq!(
exit_status.code(),
Some(0),
"mcpls should exit with status 0 via its own shutdown path even when SIGTERM \
arrives before the MCP handshake completes, not be killed by the default \
SIGTERM disposition (issue #318 regression)"
);
return Ok(());
}
assert!(
std::time::Instant::now() < deadline,
"mcpls did not exit within 5s of SIGTERM sent before the handshake completed \
(issue #318 regression)"
);
std::thread::sleep(std::time::Duration::from_millis(50));
}
}
fn spawn_mcpls_with_workspace_config(extra_workspace_toml: &str) -> Result<(TempDir, McpClient)> {
let workspace_path = rust_workspace_path();
let config_dir = TempDir::new()?;
let config_path = config_dir.path().join("mcpls.toml");
let toml_content = format!(
r#"
[workspace]
roots = ["{}"]
{extra_workspace_toml}
[[lsp_servers]]
language_id = "rust"
command = "rust-analyzer"
args = []
file_patterns = ["**/*.rs"]
"#,
workspace_path.to_string_lossy().replace('\\', "\\\\")
);
std::fs::write(&config_path, toml_content)?;
let mut client = McpClient::spawn_with_args(&[
"--config",
config_path
.to_str()
.ok_or_else(|| anyhow::anyhow!("Invalid config path"))?,
])?;
client.initialize()?;
Ok((config_dir, client))
}
fn hover_args(path: &std::path::Path) -> serde_json::Value {
json!({
"file_path": path.to_string_lossy(),
"line": 1,
"character": 1,
})
}
fn call_hover_past_server_init(
client: &mut McpClient,
path: &std::path::Path,
deadline: std::time::Instant,
) -> Result<serde_json::Value> {
loop {
match client.call_tool("get_hover", &hover_args(path)) {
Err(e) if e.to_string().contains("initializing") => {
assert!(
std::time::Instant::now() < deadline,
"rust-analyzer never finished initializing: {e}"
);
std::thread::sleep(std::time::Duration::from_millis(200));
}
other => return other,
}
}
}
#[test]
#[ignore = "Requires mcpls binary built and rust-analyzer installed"]
fn test_e2e_max_documents_config_enforced() -> Result<()> {
if !rust_analyzer_available() {
eprintln!("Skipping: rust-analyzer not available");
return Ok(());
}
let max_documents = 3;
let workspace_path = rust_workspace_path();
let (_config_dir, mut client) =
spawn_mcpls_with_workspace_config(&format!("max_documents = {max_documents}"))?;
let files = [
workspace_path.join("src/lib.rs"),
workspace_path.join("src/types.rs"),
workspace_path.join("src/functions.rs"),
workspace_path.join("extras/untouched.rs"),
workspace_path.join("extras/bad_format.rs"),
];
assert!(
files.len() > max_documents + 1,
"fixture must provide at least two more files than the configured limit to \
exercise eviction repeatedly, not just once"
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
for (i, path) in files.iter().enumerate() {
let result = call_hover_past_server_init(&mut client, path, deadline);
assert!(
result.is_ok(),
"opening document {} of {} must succeed: the cap is a real bound that evicts \
the least-recently-used document, not a ceiling that wedges the session once \
reached; got: {:?}",
i + 1,
files.len(),
result.err()
);
}
let result = call_hover_past_server_init(&mut client, &files[0], deadline);
assert!(
result.is_ok(),
"re-opening the first (evicted) document must succeed: {:?}",
result.err()
);
Ok(())
}
#[test]
#[ignore = "Requires mcpls binary built and rust-analyzer installed"]
fn test_e2e_max_file_size_config_enforced() -> Result<()> {
if !rust_analyzer_available() {
eprintln!("Skipping: rust-analyzer not available");
return Ok(());
}
let workspace_path = rust_workspace_path();
let (_config_dir, mut client) = spawn_mcpls_with_workspace_config("max_file_size = 1")?;
let lib_rs = workspace_path.join("src/lib.rs");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
let result = call_hover_past_server_init(&mut client, &lib_rs, deadline);
match result {
Err(e) => assert!(
e.to_string().contains("file size limit exceeded"),
"expected a FileSizeLimitExceeded error, got: {e}"
),
Ok(_) => panic!("a file exceeding the configured max_file_size must be rejected"),
}
Ok(())
}