use serde_json::json;
use std::sync::Arc;
use tempfile::NamedTempFile;
use things3_cli::mcp::io_wrapper::{McpIo, MockIo};
use things3_cli::mcp::start_mcp_server_generic;
use things3_core::{ThingsConfig, ThingsDatabase};
use tokio::time::{timeout, Duration};
async fn create_test_db() -> (NamedTempFile, Arc<ThingsDatabase>) {
let temp_file = NamedTempFile::new().unwrap();
let db_path = temp_file.path();
things3_core::test_utils::create_test_database(db_path)
.await
.unwrap();
let db = ThingsDatabase::new(db_path).await.unwrap();
(temp_file, Arc::new(db))
}
async fn send_request_read_response(
client_io: &mut MockIo,
request: serde_json::Value,
) -> serde_json::Value {
let request_str = serde_json::to_string(&request).unwrap();
client_io.write_line(&request_str).await.unwrap();
client_io.flush().await.unwrap();
let response_line = timeout(Duration::from_secs(2), client_io.read_line())
.await
.expect("Timeout waiting for response")
.expect("IO error reading response")
.expect("EOF when expecting response");
serde_json::from_str(&response_line).unwrap()
}
#[tokio::test]
async fn test_initialize_handshake() {
let (_temp, db) = create_test_db().await;
let config = ThingsConfig::default();
let (server_io, mut client_io) = MockIo::create_pair(4096);
let server_handle =
tokio::spawn(async move { start_mcp_server_generic(db, config, server_io).await });
let initialize_request = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {
"name": "test-client",
"version": "1.0.0"
}
}
});
let response = send_request_read_response(&mut client_io, initialize_request).await;
assert_eq!(response["jsonrpc"], "2.0");
assert_eq!(response["id"], 1);
assert!(response["result"].is_object());
assert_eq!(response["result"]["protocolVersion"], "2024-11-05");
assert!(response["result"]["capabilities"].is_object());
assert_eq!(response["result"]["serverInfo"]["name"], "things3-mcp");
let initialized_notification = json!({
"jsonrpc": "2.0",
"method": "notifications/initialized"
});
let notification_str = serde_json::to_string(&initialized_notification).unwrap();
client_io.write_line(¬ification_str).await.unwrap();
client_io.flush().await.unwrap();
drop(client_io);
let result = timeout(Duration::from_secs(2), server_handle).await;
assert!(result.is_ok(), "Server should complete");
assert!(result.unwrap().is_ok(), "Server should not error");
}
#[tokio::test]
async fn test_initialize_response_structure() {
let (_temp, db) = create_test_db().await;
let config = ThingsConfig::default();
let (server_io, mut client_io) = MockIo::create_pair(4096);
tokio::spawn(async move { start_mcp_server_generic(db, config, server_io).await });
let initialize_request = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {}
});
let response = send_request_read_response(&mut client_io, initialize_request).await;
let capabilities = &response["result"]["capabilities"];
assert!(capabilities["tools"].is_object());
assert!(capabilities["resources"].is_object());
assert!(capabilities["prompts"].is_object());
let server_info = &response["result"]["serverInfo"];
assert_eq!(server_info["name"], "things3-mcp");
assert!(server_info["version"].is_string());
}
#[tokio::test]
async fn test_tools_list() {
let (_temp, db) = create_test_db().await;
let config = ThingsConfig::default();
let (server_io, mut client_io) = MockIo::create_pair(4096);
tokio::spawn(async move { start_mcp_server_generic(db, config, server_io).await });
let tools_list_request = json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list"
});
let response = send_request_read_response(&mut client_io, tools_list_request).await;
assert_eq!(response["jsonrpc"], "2.0");
assert_eq!(response["id"], 2);
assert!(
response["result"].is_array(),
"Result should be an array of tools"
);
let tools = response["result"].as_array().unwrap();
assert!(!tools.is_empty(), "Should have at least one tool");
let first_tool = &tools[0];
assert!(first_tool["name"].is_string());
assert!(first_tool["description"].is_string());
assert!(first_tool["inputSchema"].is_object() || first_tool["input_schema"].is_object());
}
#[tokio::test]
async fn test_tools_call_get_today() {
let (_temp, db) = create_test_db().await;
let config = ThingsConfig::default();
let (server_io, mut client_io) = MockIo::create_pair(4096);
tokio::spawn(async move { start_mcp_server_generic(db, config, server_io).await });
let tools_call_request = json!({
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "get_today",
"arguments": {}
}
});
let response = send_request_read_response(&mut client_io, tools_call_request).await;
assert_eq!(response["jsonrpc"], "2.0");
assert_eq!(response["id"], 3);
assert!(response["result"].is_object());
assert!(response["result"]["content"].is_array());
let is_error = response["result"]["is_error"].as_bool().unwrap_or(false);
assert!(!is_error, "Tool call should not error");
}
#[tokio::test]
async fn test_tools_call_get_inbox() {
let (_temp, db) = create_test_db().await;
let config = ThingsConfig::default();
let (server_io, mut client_io) = MockIo::create_pair(4096);
tokio::spawn(async move { start_mcp_server_generic(db, config, server_io).await });
let tools_call_request = json!({
"jsonrpc": "2.0",
"id": 4,
"method": "tools/call",
"params": {
"name": "get_inbox",
"arguments": {
"limit": 10
}
}
});
let response = send_request_read_response(&mut client_io, tools_call_request).await;
assert_eq!(response["jsonrpc"], "2.0");
assert_eq!(response["id"], 4);
assert!(response["result"].is_object());
let is_error = response["result"]["is_error"].as_bool().unwrap_or(false);
assert!(!is_error, "Tool call should not error");
}
#[tokio::test]
async fn test_tools_call_nonexistent_tool() {
let (_temp, db) = create_test_db().await;
let config = ThingsConfig::default();
let (server_io, mut client_io) = MockIo::create_pair(4096);
let server_handle =
tokio::spawn(async move { start_mcp_server_generic(db, config, server_io).await });
let tools_call_request = json!({
"jsonrpc": "2.0",
"id": 5,
"method": "tools/call",
"params": {
"name": "nonexistent_tool",
"arguments": {}
}
});
let request_str = serde_json::to_string(&tools_call_request).unwrap();
client_io.write_line(&request_str).await.unwrap();
client_io.flush().await.unwrap();
let result = timeout(Duration::from_millis(500), client_io.read_line()).await;
if let Ok(Ok(Some(response_line))) = result {
let response: serde_json::Value = serde_json::from_str(&response_line).unwrap();
assert_eq!(response["jsonrpc"], "2.0");
assert_eq!(response["id"], 5);
let is_error = response["result"]["is_error"].as_bool().unwrap_or(false);
assert!(
is_error || response["error"].is_object(),
"Should indicate error for nonexistent tool"
);
} else {
drop(client_io);
let _ = timeout(Duration::from_secs(1), server_handle).await;
}
}
#[tokio::test]
async fn test_resources_list() {
let (_temp, db) = create_test_db().await;
let config = ThingsConfig::default();
let (server_io, mut client_io) = MockIo::create_pair(4096);
tokio::spawn(async move { start_mcp_server_generic(db, config, server_io).await });
let resources_list_request = json!({
"jsonrpc": "2.0",
"id": 6,
"method": "resources/list"
});
let response = send_request_read_response(&mut client_io, resources_list_request).await;
assert_eq!(response["jsonrpc"], "2.0");
assert_eq!(response["id"], 6);
assert!(
response["result"].is_array(),
"Result should be an array of resources"
);
}
#[tokio::test]
async fn test_resources_read() {
let (_temp, db) = create_test_db().await;
let config = ThingsConfig::default();
let (server_io, mut client_io) = MockIo::create_pair(4096);
let server_handle =
tokio::spawn(async move { start_mcp_server_generic(db, config, server_io).await });
let resources_read_request = json!({
"jsonrpc": "2.0",
"id": 7,
"method": "resources/read",
"params": {
"uri": "things3://today"
}
});
let request_str = serde_json::to_string(&resources_read_request).unwrap();
client_io.write_line(&request_str).await.unwrap();
client_io.flush().await.unwrap();
let result = timeout(Duration::from_millis(500), client_io.read_line()).await;
if let Ok(Ok(Some(response_line))) = result {
let response: serde_json::Value = serde_json::from_str(&response_line).unwrap();
assert_eq!(response["jsonrpc"], "2.0");
assert_eq!(response["id"], 7);
assert!(response["result"].is_object() || response["error"].is_object());
} else {
drop(client_io);
let _ = timeout(Duration::from_secs(1), server_handle).await;
}
}
#[tokio::test]
async fn test_prompts_list() {
let (_temp, db) = create_test_db().await;
let config = ThingsConfig::default();
let (server_io, mut client_io) = MockIo::create_pair(4096);
tokio::spawn(async move { start_mcp_server_generic(db, config, server_io).await });
let prompts_list_request = json!({
"jsonrpc": "2.0",
"id": 8,
"method": "prompts/list"
});
let response = send_request_read_response(&mut client_io, prompts_list_request).await;
assert_eq!(response["jsonrpc"], "2.0");
assert_eq!(response["id"], 8);
assert!(
response["result"].is_array(),
"Result should be an array of prompts"
);
}
#[tokio::test]
async fn test_prompts_get() {
let (_temp, db) = create_test_db().await;
let config = ThingsConfig::default();
let (server_io, mut client_io) = MockIo::create_pair(4096);
let server_handle =
tokio::spawn(async move { start_mcp_server_generic(db, config, server_io).await });
let prompts_get_request = json!({
"jsonrpc": "2.0",
"id": 9,
"method": "prompts/get",
"params": {
"name": "task_summary",
"arguments": {}
}
});
let request_str = serde_json::to_string(&prompts_get_request).unwrap();
client_io.write_line(&request_str).await.unwrap();
client_io.flush().await.unwrap();
let result = timeout(Duration::from_millis(500), client_io.read_line()).await;
if let Ok(Ok(Some(response_line))) = result {
let response: serde_json::Value = serde_json::from_str(&response_line).unwrap();
assert_eq!(response["jsonrpc"], "2.0");
assert_eq!(response["id"], 9);
assert!(response["result"].is_object() || response["error"].is_object());
} else {
drop(client_io);
let _ = timeout(Duration::from_secs(1), server_handle).await;
}
}
#[tokio::test]
async fn test_malformed_json() {
let (_temp, db) = create_test_db().await;
let config = ThingsConfig::default();
let (server_io, mut client_io) = MockIo::create_pair(4096);
let server_handle =
tokio::spawn(async move { start_mcp_server_generic(db, config, server_io).await });
client_io.write_line("{invalid json}").await.unwrap();
client_io.flush().await.unwrap();
drop(client_io);
let result = timeout(Duration::from_secs(2), server_handle).await;
assert!(result.is_ok(), "Server should complete");
}
#[tokio::test]
async fn test_missing_method() {
let (_temp, db) = create_test_db().await;
let config = ThingsConfig::default();
let (server_io, mut client_io) = MockIo::create_pair(4096);
tokio::spawn(async move { start_mcp_server_generic(db, config, server_io).await });
let request_without_method = json!({
"jsonrpc": "2.0",
"id": 10,
"params": {}
});
let request_str = serde_json::to_string(&request_without_method).unwrap();
client_io.write_line(&request_str).await.unwrap();
client_io.flush().await.unwrap();
let result = timeout(Duration::from_millis(500), client_io.read_line()).await;
assert!(result.is_ok() || result.is_err());
}
#[tokio::test]
async fn test_unknown_method() {
let (_temp, db) = create_test_db().await;
let config = ThingsConfig::default();
let (server_io, mut client_io) = MockIo::create_pair(4096);
tokio::spawn(async move { start_mcp_server_generic(db, config, server_io).await });
let unknown_method_request = json!({
"jsonrpc": "2.0",
"id": 11,
"method": "unknown/method"
});
let response = send_request_read_response(&mut client_io, unknown_method_request).await;
assert_eq!(response["jsonrpc"], "2.0");
assert_eq!(response["id"], 11);
assert!(response["error"].is_object());
assert_eq!(response["error"]["code"], -32601); }
#[tokio::test]
async fn test_empty_line_handling() {
let (_temp, db) = create_test_db().await;
let config = ThingsConfig::default();
let (server_io, mut client_io) = MockIo::create_pair(4096);
tokio::spawn(async move { start_mcp_server_generic(db, config, server_io).await });
client_io.write_line("").await.unwrap();
client_io.write_line("").await.unwrap();
client_io.flush().await.unwrap();
let valid_request = json!({
"jsonrpc": "2.0",
"id": 12,
"method": "tools/list"
});
let response = send_request_read_response(&mut client_io, valid_request).await;
assert_eq!(response["jsonrpc"], "2.0");
assert_eq!(response["id"], 12);
assert!(response["result"].is_array());
}
#[tokio::test]
async fn test_multiple_sequential_requests() {
let (_temp, db) = create_test_db().await;
let config = ThingsConfig::default();
let (server_io, mut client_io) = MockIo::create_pair(8192);
tokio::spawn(async move { start_mcp_server_generic(db, config, server_io).await });
for i in 1..=5 {
let request = json!({
"jsonrpc": "2.0",
"id": i,
"method": "tools/list"
});
let response = send_request_read_response(&mut client_io, request).await;
assert_eq!(response["jsonrpc"], "2.0");
assert_eq!(response["id"], i);
assert!(response["result"].is_array());
}
}
#[tokio::test]
async fn test_notification_no_response() {
let (_temp, db) = create_test_db().await;
let config = ThingsConfig::default();
let (server_io, mut client_io) = MockIo::create_pair(4096);
tokio::spawn(async move { start_mcp_server_generic(db, config, server_io).await });
let notification = json!({
"jsonrpc": "2.0",
"method": "notifications/initialized"
});
let notification_str = serde_json::to_string(¬ification).unwrap();
client_io.write_line(¬ification_str).await.unwrap();
client_io.flush().await.unwrap();
let request = json!({
"jsonrpc": "2.0",
"id": 13,
"method": "tools/list"
});
let response = send_request_read_response(&mut client_io, request).await;
assert_eq!(response["jsonrpc"], "2.0");
assert_eq!(response["id"], 13);
}
#[tokio::test]
async fn test_start_mcp_server_with_config() {
use things3_cli::mcp::start_mcp_server_with_config_generic;
use things3_core::McpServerConfig;
let (_temp, db) = create_test_db().await;
let mcp_config = McpServerConfig::default();
let (server_io, mut client_io) = MockIo::create_pair(4096);
tokio::spawn(
async move { start_mcp_server_with_config_generic(db, mcp_config, server_io).await },
);
let initialize_request = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {}
});
let response = send_request_read_response(&mut client_io, initialize_request).await;
assert_eq!(response["jsonrpc"], "2.0");
assert_eq!(response["id"], 1);
assert_eq!(response["result"]["protocolVersion"], "2024-11-05");
}
#[tokio::test]
async fn test_start_mcp_server_with_config_tools() {
use things3_cli::mcp::start_mcp_server_with_config_generic;
use things3_core::McpServerConfig;
let (_temp, db) = create_test_db().await;
let mcp_config = McpServerConfig::default();
let (server_io, mut client_io) = MockIo::create_pair(4096);
tokio::spawn(
async move { start_mcp_server_with_config_generic(db, mcp_config, server_io).await },
);
let tools_call_request = json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "get_today",
"arguments": {}
}
});
let response = send_request_read_response(&mut client_io, tools_call_request).await;
assert_eq!(response["jsonrpc"], "2.0");
assert_eq!(response["id"], 2);
assert!(response["result"].is_object());
}
#[tokio::test]
async fn test_io_error_handling() {
let (_temp, db) = create_test_db().await;
let config = ThingsConfig::default();
let (server_io, client_io) = MockIo::create_pair(4096);
let server_handle =
tokio::spawn(async move { start_mcp_server_generic(db, config, server_io).await });
drop(client_io);
let result = timeout(Duration::from_secs(2), server_handle).await;
assert!(result.is_ok(), "Server should handle EOF gracefully");
assert!(result.unwrap().is_ok(), "Server should not error on EOF");
}
#[tokio::test]
async fn test_json_serialization_coverage() {
let (_temp, db) = create_test_db().await;
let config = ThingsConfig::default();
let (server_io, mut client_io) = MockIo::create_pair(4096);
tokio::spawn(async move { start_mcp_server_generic(db, config, server_io).await });
let requests = vec![
json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}),
json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}),
json!({"jsonrpc": "2.0", "id": 3, "method": "resources/list"}),
json!({"jsonrpc": "2.0", "id": 4, "method": "prompts/list"}),
];
for request in requests {
let response = send_request_read_response(&mut client_io, request).await;
assert_eq!(response["jsonrpc"], "2.0");
}
}
#[tokio::test]
async fn test_mixed_requests_and_notifications() {
let (_temp, db) = create_test_db().await;
let config = ThingsConfig::default();
let (server_io, mut client_io) = MockIo::create_pair(4096);
tokio::spawn(async move { start_mcp_server_generic(db, config, server_io).await });
let notification = json!({
"jsonrpc": "2.0",
"method": "notifications/custom"
});
let notification_str = serde_json::to_string(¬ification).unwrap();
client_io.write_line(¬ification_str).await.unwrap();
client_io.flush().await.unwrap();
let request = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list"
});
let response = send_request_read_response(&mut client_io, request).await;
assert_eq!(response["jsonrpc"], "2.0");
assert_eq!(response["id"], 1);
}
#[tokio::test]
async fn test_all_available_tools() {
let (_temp, db) = create_test_db().await;
let config = ThingsConfig::default();
let (server_io, mut client_io) = MockIo::create_pair(8192);
tokio::spawn(async move { start_mcp_server_generic(db, config, server_io).await });
let tools_list_request = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list"
});
let response = send_request_read_response(&mut client_io, tools_list_request).await;
let tools = response["result"].as_array().unwrap();
assert!(!tools.is_empty(), "Should have at least one tool");
let tool_tests = ["get_today", "get_inbox"];
for (idx, tool_name) in tool_tests.iter().enumerate() {
let tools_call_request = json!({
"jsonrpc": "2.0",
"id": idx + 2,
"method": "tools/call",
"params": {
"name": tool_name,
"arguments": {}
}
});
let response = send_request_read_response(&mut client_io, tools_call_request).await;
assert_eq!(response["jsonrpc"], "2.0");
assert!(response["result"].is_object());
}
}
#[tokio::test]
async fn test_large_response_handling() {
let (_temp, db) = create_test_db().await;
let config = ThingsConfig::default();
let (server_io, mut client_io) = MockIo::create_pair(65536);
tokio::spawn(async move { start_mcp_server_generic(db, config, server_io).await });
let request = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_projects",
"arguments": {}
}
});
let response = send_request_read_response(&mut client_io, request).await;
assert_eq!(response["jsonrpc"], "2.0");
assert_eq!(response["id"], 1);
}
#[tokio::test]
async fn test_sequential_initialize_calls() {
let (_temp, db) = create_test_db().await;
let config = ThingsConfig::default();
let (server_io, mut client_io) = MockIo::create_pair(4096);
tokio::spawn(async move { start_mcp_server_generic(db, config, server_io).await });
for i in 1..=3 {
let initialize_request = json!({
"jsonrpc": "2.0",
"id": i,
"method": "initialize",
"params": {}
});
let response = send_request_read_response(&mut client_io, initialize_request).await;
assert_eq!(response["jsonrpc"], "2.0");
assert_eq!(response["id"], i);
assert_eq!(response["result"]["protocolVersion"], "2024-11-05");
}
}
#[tokio::test]
async fn test_config_with_empty_lines() {
use things3_cli::mcp::start_mcp_server_with_config_generic;
use things3_core::McpServerConfig;
let (_temp, db) = create_test_db().await;
let mcp_config = McpServerConfig::default();
let (server_io, mut client_io) = MockIo::create_pair(4096);
tokio::spawn(
async move { start_mcp_server_with_config_generic(db, mcp_config, server_io).await },
);
client_io.write_line("").await.unwrap();
client_io.write_line("").await.unwrap();
client_io.flush().await.unwrap();
let request = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {}
});
let response = send_request_read_response(&mut client_io, request).await;
assert_eq!(response["jsonrpc"], "2.0");
assert_eq!(response["id"], 1);
}
#[tokio::test]
async fn test_rapid_requests() {
let (_temp, db) = create_test_db().await;
let config = ThingsConfig::default();
let (server_io, mut client_io) = MockIo::create_pair(32768);
tokio::spawn(async move { start_mcp_server_generic(db, config, server_io).await });
for i in 1..=20 {
let request = json!({
"jsonrpc": "2.0",
"id": i,
"method": "tools/list"
});
let response = send_request_read_response(&mut client_io, request).await;
assert_eq!(response["jsonrpc"], "2.0");
assert_eq!(response["id"], i);
}
}