#![cfg(feature = "cli")]
use leindex::cli::mcp::handlers::{
ContextHandler, DeepAnalyzeHandler, DiagnosticsHandler, EditApplyHandler, EditPreviewHandler,
FileSummaryHandler, GitStatusHandler, GrepSymbolsHandler, ImpactAnalysisHandler, IndexHandler,
PhaseAnalysisAliasHandler, PhaseAnalysisHandler, ProjectMapHandler, ReadFileHandler,
ReadSymbolHandler, RenameSymbolHandler, SearchHandler, SymbolLookupHandler, TextSearchHandler,
ToolHandler, WriteHandler,
};
use leindex::cli::mcp::protocol::{JsonRpcRequest, JsonRpcResponse};
use leindex::cli::mcp::server::{handle_tool_call, list_tools_json};
use std::sync::Arc;
use tempfile::TempDir;
fn all_handlers() -> Vec<ToolHandler> {
vec![
ToolHandler::DeepAnalyze(DeepAnalyzeHandler),
ToolHandler::Diagnostics(DiagnosticsHandler),
ToolHandler::Index(IndexHandler),
ToolHandler::Context(ContextHandler),
ToolHandler::Search(SearchHandler),
ToolHandler::PhaseAnalysis(PhaseAnalysisHandler),
ToolHandler::PhaseAnalysisAlias(PhaseAnalysisAliasHandler),
ToolHandler::FileSummary(FileSummaryHandler),
ToolHandler::SymbolLookup(SymbolLookupHandler),
ToolHandler::ProjectMap(ProjectMapHandler),
ToolHandler::GrepSymbols(GrepSymbolsHandler),
ToolHandler::ReadSymbol(ReadSymbolHandler),
ToolHandler::Write(WriteHandler),
ToolHandler::EditPreview(EditPreviewHandler),
ToolHandler::EditApply(EditApplyHandler),
ToolHandler::RenameSymbol(RenameSymbolHandler),
ToolHandler::ImpactAnalysis(ImpactAnalysisHandler),
ToolHandler::TextSearch(TextSearchHandler),
ToolHandler::ReadFile(ReadFileHandler),
ToolHandler::GitStatus(GitStatusHandler),
]
}
fn make_state(tmp: &TempDir) -> Arc<leindex::cli::ProjectRegistry> {
let leindex =
leindex::cli::leindex::LeIndex::new(tmp.path()).expect("Failed to create LeIndex for test");
Arc::new(leindex::cli::ProjectRegistry::with_initial_project(
5, leindex,
))
}
fn parse_request(json: &str) -> JsonRpcRequest {
serde_json::from_str(json).expect("Failed to parse JsonRpcRequest")
}
fn assert_no_double_newline(resp: &JsonRpcResponse) {
let serialized = serde_json::to_string(resp).expect("Failed to serialize response");
assert!(
!serialized.contains("\n\n"),
"Response contains double-newline (MCP transport bug): {:?}",
&serialized[..serialized.len().min(200)]
);
let with_writeln = format!("{}\n", serialized);
assert_eq!(
with_writeln.matches('\n').count(),
1,
"writeln! with {{}} resp should produce exactly one trailing newline"
);
}
#[test]
fn test_initialize_request_parses_correctly() {
let json = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{}}}"#;
let req = parse_request(json);
assert_eq!(req.method, "initialize");
assert_eq!(req.id, Some(serde_json::json!(1)));
}
#[test]
fn test_initialize_response_format() {
let resp = JsonRpcResponse::success(
serde_json::json!(1),
serde_json::json!({
"protocolVersion": "2024-11-05",
"capabilities": { "tools": {} },
"serverInfo": { "name": "leindex", "version": "0.1.0" }
}),
);
let serialized = serde_json::to_string(&resp).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&serialized).unwrap();
assert_eq!(parsed["jsonrpc"], "2.0");
assert_eq!(parsed["id"], 1);
assert!(parsed["result"]["protocolVersion"].is_string());
assert!(parsed["result"]["capabilities"].is_object());
assert!(parsed["result"]["serverInfo"]["name"].is_string());
assert_no_double_newline(&resp);
}
#[test]
fn test_notification_has_no_id_field() {
let json = r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#;
let req = parse_request(json);
assert_eq!(req.method, "notifications/initialized");
assert!(req.id.is_none(), "Notification should not have an id");
}
#[test]
fn test_no_double_newline_in_error_response() {
let err =
leindex::cli::mcp::protocol::JsonRpcError::method_not_found("unknown_method".to_string());
let resp = JsonRpcResponse::error(serde_json::json!(42), err);
assert_no_double_newline(&resp);
}
#[test]
fn test_no_double_newline_in_success_response() {
let resp = JsonRpcResponse::success(serde_json::json!(99), serde_json::json!({ "tools": [] }));
assert_no_double_newline(&resp);
}
#[test]
fn test_tools_list_returns_20_tools() {
let handlers = all_handlers();
let result = list_tools_json(&handlers);
let tools = result["tools"].as_array().expect("tools must be an array");
assert_eq!(
tools.len(),
20,
"Expected exactly 20 registered tools, got {}",
tools.len()
);
}
#[test]
fn test_tools_list_all_expected_names_present() {
let handlers = all_handlers();
let result = list_tools_json(&handlers);
let tools = result["tools"].as_array().unwrap();
let names: Vec<&str> = tools
.iter()
.map(|t| t["name"].as_str().expect("tool name must be a string"))
.collect();
let expected_names = [
"leindex.index",
"leindex.search",
"leindex.deep-analyze",
"leindex.context",
"leindex.diagnostics",
"leindex.phase-analysis",
"phase_analysis",
"leindex.file-summary",
"leindex.symbol-lookup",
"leindex.project-map",
"leindex.grep-symbols",
"leindex.read-symbol",
"leindex.write",
"leindex.edit-preview",
"leindex.edit-apply",
"leindex.rename-symbol",
"leindex.impact-analysis",
"leindex.text-search",
"leindex.read-file",
"leindex.git-status",
];
for expected in &expected_names {
assert!(
names.contains(expected),
"Missing tool '{}' from tools/list. Got: {:?}",
expected,
names
);
}
}
#[test]
fn test_tools_list_every_tool_has_description_and_schema() {
let handlers = all_handlers();
let result = list_tools_json(&handlers);
let tools = result["tools"].as_array().unwrap();
for tool in tools {
let name = tool["name"].as_str().unwrap_or("<unnamed>");
let desc = tool["description"].as_str().unwrap_or("");
assert!(
!desc.is_empty(),
"Tool '{}' has empty or missing description",
name
);
assert!(
desc.len() <= 300,
"Tool '{}' description exceeds 300 chars ({} chars): {}",
name,
desc.len(),
desc
);
assert!(
tool["inputSchema"].is_object(),
"Tool '{}' has missing or non-object inputSchema",
name
);
}
}
fn make_tool_call(id: i64, tool_name: &str, args: serde_json::Value) -> JsonRpcRequest {
parse_request(&format!(
r#"{{"jsonrpc":"2.0","id":{},"method":"tools/call","params":{{"name":"{}","arguments":{}}}}}"#,
id, tool_name, args
))
}
#[tokio::test]
async fn test_tools_call_unknown_tool_returns_error() {
let tmp = TempDir::new().unwrap();
let state = make_state(&tmp);
let handlers = all_handlers();
let req = make_tool_call(1, "leindex_nonexistent_tool", serde_json::json!({}));
let result = handle_tool_call(&state, &handlers, &req).await;
assert!(result.is_err(), "Expected error for unknown tool");
}
#[tokio::test]
async fn test_tools_call_file_summary_unindexed_returns_structured_response() {
let tmp = TempDir::new().unwrap();
let state = make_state(&tmp);
let handlers = all_handlers();
let req = make_tool_call(
2,
"leindex.file-summary",
serde_json::json!({ "file_path": "/nonexistent/file.rs" }),
);
let result = handle_tool_call(&state, &handlers, &req).await;
assert!(
result.is_ok(),
"Tool call should return Ok with structured error response"
);
let response = result.unwrap();
assert!(
response.get("isError").is_some() || response.get("content").is_some(),
"Response must have 'isError' or 'content' field. Got: {:?}",
response
);
}
#[tokio::test]
async fn test_tools_call_symbol_lookup_unindexed_returns_structured_response() {
let tmp = TempDir::new().unwrap();
let state = make_state(&tmp);
let handlers = all_handlers();
let req = make_tool_call(
3,
"leindex.symbol-lookup",
serde_json::json!({ "symbol": "some_function" }),
);
let result = handle_tool_call(&state, &handlers, &req).await;
assert!(result.is_ok());
let response = result.unwrap();
assert!(
response.get("isError").is_some() || response.get("content").is_some(),
"Response must have 'isError' or 'content' field"
);
}
#[tokio::test]
async fn test_tools_call_project_map_unindexed_returns_structured_response() {
let tmp = TempDir::new().unwrap();
let state = make_state(&tmp);
let handlers = all_handlers();
let req = make_tool_call(4, "leindex.project-map", serde_json::json!({}));
let result = handle_tool_call(&state, &handlers, &req).await;
assert!(result.is_ok());
let response = result.unwrap();
assert!(
response.get("isError").is_some() || response.get("content").is_some(),
"Response must have 'isError' or 'content' field"
);
}
#[tokio::test]
async fn test_tools_call_edit_preview_unindexed_returns_structured_response() {
let tmp = TempDir::new().unwrap();
let state = make_state(&tmp);
let handlers = all_handlers();
let req = make_tool_call(
5,
"leindex.edit-preview",
serde_json::json!({
"file_path": "/nonexistent/file.rs",
"changes": [{"type": "replace_text", "old_text": "foo", "new_text": "bar"}]
}),
);
let result = handle_tool_call(&state, &handlers, &req).await;
assert!(result.is_ok());
let response = result.unwrap();
assert!(response.get("isError").is_some() || response.get("content").is_some());
}
#[tokio::test]
async fn test_tools_call_diagnostics_returns_ok() {
let tmp = TempDir::new().unwrap();
let state = make_state(&tmp);
let handlers = all_handlers();
let req = make_tool_call(6, "leindex.diagnostics", serde_json::json!({}));
let result = handle_tool_call(&state, &handlers, &req).await;
assert!(result.is_ok());
let response = result.unwrap();
assert_eq!(
response.get("isError").and_then(|v| v.as_bool()),
Some(false),
"Diagnostics should succeed: {:?}",
response
);
}
#[test]
fn test_notification_request_has_no_id() {
let json = r#"{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}"#;
let req: JsonRpcRequest = serde_json::from_str(json).unwrap();
assert!(
req.id.is_none(),
"Notification must have no id (so callers know not to send a response)"
);
}
#[test]
fn test_jsonrpc_response_serialization_is_single_line() {
let resp = JsonRpcResponse::success(
serde_json::json!(1),
serde_json::json!({"tools": [{"name": "LeIndex [Index]"}]}),
);
let s = serde_json::to_string(&resp).unwrap();
assert!(
!s.contains('\n'),
"serde_json::to_string must not produce embedded newlines. Got: {}",
s
);
}