#![cfg(feature = "mcp")]
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
use recursive::error::{Error, Result};
use recursive::mcp::{McpClient, McpServer, McpTool, McpToolSpec};
const TEST_READ_TIMEOUT: Duration = Duration::from_secs(2);
async fn spawn_mock(mode: &str) -> Result<McpClient> {
let server = McpServer {
name: "mock".to_string(),
command: env!("CARGO_BIN_EXE_mock_mcp_server").to_string(),
args: vec![mode.to_string()],
url: None,
};
McpClient::spawn_with_timeout(&server, TEST_READ_TIMEOUT).await
}
#[tokio::test]
async fn initialize_handshake_and_list_tools() {
let mut client = spawn_mock("echo").await.expect("spawn mock server");
let tools = client.list_tools().await.expect("list_tools");
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].name, "echo");
assert!(tools[0].description.contains("Echo"));
}
#[tokio::test]
async fn call_tool_returns_text() {
let mut client = spawn_mock("echo").await.expect("spawn mock server");
let result = client
.call_tool("echo", serde_json::json!({"message": "hello"}))
.await
.expect("call_tool");
assert!(result.contains("Echo: hello"));
}
#[tokio::test]
async fn malformed_server_errors_cleanly() {
let result = spawn_mock("malformed").await;
assert!(result.is_err(), "should fail on non-JSON response");
}
#[tokio::test]
async fn mcp_tool_roundtrip() {
let client = spawn_mock("echo").await.expect("spawn mock server");
let client = Arc::new(Mutex::new(client));
let spec = McpToolSpec {
name: "echo".to_string(),
description: "Echo back input".to_string(),
input_schema: serde_json::json!({"type":"object","properties":{"message":{"type":"string"}}}),
annotations: None,
};
use recursive::tools::Tool;
let tool = McpTool::new(client, spec, "mock");
let tool_spec = tool.spec();
assert_eq!(tool_spec.name, "mcp__mock__echo");
assert!(tool_spec.description.contains("[mcp:mock]"));
let result = tool
.execute(serde_json::json!({"message": "hello"}))
.await
.expect("tool execute");
assert!(result.contains("Echo: hello"));
}
#[tokio::test]
async fn server_timeout_errors_cleanly() {
let result = spawn_mock("timeout").await;
assert!(result.is_err(), "should fail on timeout");
let err = result.unwrap_err().to_string();
assert!(
err.contains("timed out") || err.contains("timeout"),
"error should mention timeout: {err}"
);
}
#[tokio::test]
async fn call_tool_with_error_response() {
let mut client = spawn_mock("error-tool").await.expect("spawn mock server");
let err = client
.call_tool("failing", serde_json::json!({}))
.await
.unwrap_err();
assert!(matches!(err, Error::Tool { .. }));
let msg = err.to_string();
assert!(msg.contains("Something went wrong"), "error: {msg}");
}
#[tokio::test]
async fn resources_list_resources() {
let mut client = spawn_mock("echo").await.expect("spawn mock server");
let resources = client.list_resources().await.expect("list_resources");
assert_eq!(resources.len(), 1);
assert_eq!(resources[0].uri, "file:///tmp/test.txt");
assert_eq!(resources[0].name, "Test File");
assert_eq!(resources[0].description.as_deref(), Some("A test file"));
assert_eq!(resources[0].mime_type.as_deref(), Some("text/plain"));
}
#[tokio::test]
async fn resources_read_resource() {
let mut client = spawn_mock("echo").await.expect("spawn mock server");
let contents = client
.read_resource("file:///tmp/test.txt")
.await
.expect("read_resource");
assert_eq!(contents.len(), 1);
assert_eq!(contents[0].uri, "file:///tmp/test.txt");
assert_eq!(contents[0].text.as_deref(), Some("Hello, world!"));
assert_eq!(contents[0].mime_type.as_deref(), Some("text/plain"));
}
#[tokio::test]
async fn resources_read_resource_with_blob() {
let mut client = spawn_mock("read-blob").await.expect("spawn mock server");
let contents = client
.read_resource("file:///tmp/image.png")
.await
.expect("read_resource");
assert_eq!(contents.len(), 1);
assert_eq!(contents[0].uri, "file:///tmp/image.png");
assert_eq!(contents[0].mime_type.as_deref(), Some("image/png"));
assert!(contents[0].blob.is_some());
assert!(contents[0].text.is_none());
}
#[tokio::test]
async fn prompts_list_prompts() {
let mut client = spawn_mock("echo").await.expect("spawn mock server");
let prompts = client.list_prompts().await.expect("list_prompts");
assert_eq!(prompts.len(), 1);
assert_eq!(prompts[0].name, "greet");
assert_eq!(prompts[0].description.as_deref(), Some("Greet someone"));
let args = prompts[0]
.arguments
.as_ref()
.expect("arguments should be present");
assert_eq!(args.len(), 1);
assert_eq!(args[0].name, "name");
assert!(args[0].required);
}
#[tokio::test]
async fn prompts_get_prompt() {
let mut client = spawn_mock("echo").await.expect("spawn mock server");
let messages = client.get_prompt("greet", None).await.expect("get_prompt");
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].role, "user");
assert_eq!(messages[0].content, "Hello, world!");
}
#[tokio::test]
async fn prompts_get_prompt_with_arguments() {
let mut client = spawn_mock("prompt-args").await.expect("spawn mock server");
let mut args = HashMap::new();
args.insert("name".to_string(), "Alice".to_string());
let messages = client
.get_prompt("greet", Some(args))
.await
.expect("get_prompt");
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].role, "user");
assert_eq!(messages[0].content, "Hello, Alice!");
}
#[tokio::test]
async fn resources_capability_not_advertised() {
let mut client = spawn_mock("tools-only").await.expect("spawn mock server");
let err = client.list_resources().await.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("does not advertise"),
"expected capability error, got: {msg}"
);
}
#[tokio::test]
async fn prompts_get_with_missing_name() {
let mut client = spawn_mock("prompt-default")
.await
.expect("spawn mock server");
let messages = client
.get_prompt("", None)
.await
.expect("get_prompt with empty name");
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].role, "assistant");
assert_eq!(messages[0].content, "default prompt");
}
#[tokio::test]
async fn resources_read_empty_content() {
let mut client = spawn_mock("empty-resources")
.await
.expect("spawn mock server");
let contents = client
.read_resource("file:///tmp/nonexistent.txt")
.await
.expect("read_resource with empty contents");
assert!(
contents.is_empty(),
"expected empty contents vec, got {} items",
contents.len()
);
}
#[tokio::test]
async fn capability_not_advertised_returns_error_for_resources() {
let mut client = spawn_mock("tools-only").await.expect("spawn mock server");
let err = client.list_resources().await.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("does not advertise"),
"error should mention capability: {msg}"
);
let err = client
.read_resource("file:///tmp/test.txt")
.await
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("does not advertise"),
"error should mention capability: {msg}"
);
}
#[tokio::test]
async fn capability_not_advertised_returns_error_for_prompts() {
let mut client = spawn_mock("tools-only").await.expect("spawn mock server");
let err = client.list_prompts().await.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("does not advertise"),
"error should mention capability: {msg}"
);
let err = client.get_prompt("greet", None).await.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("does not advertise"),
"error should mention capability: {msg}"
);
}