use axum::http::{HeaderName, HeaderValue};
use axum_test::TestServer;
use crw_core::config::AppConfig;
use crw_core::mcp::{tool_output_schema, tool_result_response};
use crw_core::types::{
ApiResponse, GroupedSearchData, ImageResult, SearchData, SearchResponseData, SearchResult,
};
use crw_server::app::create_app;
use crw_server::state::AppState;
use serde_json::{Value, json};
fn test_app() -> TestServer {
let config: AppConfig = toml::from_str("").unwrap();
let state = AppState::new(config).expect("AppState::new failed");
let app = create_app(state);
TestServer::new(app)
}
fn mcp_request(
method: &str,
id: serde_json::Value,
params: serde_json::Value,
) -> serde_json::Value {
json!({
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params
})
}
#[tokio::test]
async fn mcp_initialize_returns_capabilities() {
let server = test_app();
let resp = server
.post("/mcp")
.content_type("application/json")
.json(&mcp_request("initialize", json!(1), json!({})))
.await;
resp.assert_status_ok();
let json: serde_json::Value = resp.json();
assert_eq!(json["jsonrpc"], "2.0");
assert_eq!(json["id"], 1);
let result = &json["result"];
assert_eq!(result["protocolVersion"], "2025-06-18");
assert!(result["capabilities"].is_object());
assert!(result["serverInfo"]["name"].is_string());
assert!(result["serverInfo"]["version"].is_string());
}
#[tokio::test]
async fn mcp_tools_list_returns_all_tools() {
let server = test_app();
let resp = server
.post("/mcp")
.content_type("application/json")
.json(&mcp_request("tools/list", json!(2), json!({})))
.await;
resp.assert_status_ok();
let json: serde_json::Value = resp.json();
let tools = json["result"]["tools"].as_array().unwrap();
assert_eq!(
tools.len(),
6,
"Should have 6 tools: scrape, crawl, check, map, search, parse_file"
);
let tool_names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
assert!(tool_names.contains(&"crw_scrape"));
assert!(tool_names.contains(&"crw_crawl"));
assert!(tool_names.contains(&"crw_check_crawl_status"));
assert!(tool_names.contains(&"crw_map"));
assert!(tool_names.contains(&"crw_search"));
assert!(tool_names.contains(&"crw_parse_file"));
}
#[tokio::test]
async fn mcp_unknown_method_error_32601() {
let server = test_app();
let resp = server
.post("/mcp")
.content_type("application/json")
.json(&mcp_request("nonexistent/method", json!(3), json!({})))
.await;
resp.assert_status_ok();
let json: serde_json::Value = resp.json();
assert_eq!(json["error"]["code"], -32601);
assert!(
json["error"]["message"]
.as_str()
.unwrap()
.contains("method not found")
);
}
#[tokio::test]
async fn mcp_invalid_json_error_32700() {
let server = test_app();
let resp = server
.post("/mcp")
.content_type("application/json")
.bytes("this is not valid json".into())
.await;
resp.assert_status_ok();
let json: serde_json::Value = resp.json();
assert_eq!(json["error"]["code"], -32700);
}
#[tokio::test]
async fn mcp_ping_returns_empty() {
let server = test_app();
let resp = server
.post("/mcp")
.content_type("application/json")
.json(&mcp_request("ping", json!(4), json!({})))
.await;
resp.assert_status_ok();
let json: serde_json::Value = resp.json();
assert_eq!(json["result"], json!({}));
}
#[tokio::test]
async fn mcp_notification_returns_202() {
let server = test_app();
let body = json!({
"jsonrpc": "2.0",
"method": "notifications/initialized"
});
let resp = server
.post("/mcp")
.content_type("application/json")
.json(&body)
.await;
resp.assert_status(axum::http::StatusCode::ACCEPTED);
}
#[tokio::test]
async fn mcp_wrong_content_type_400() {
let server = test_app();
let resp = server
.post("/mcp")
.content_type("text/plain")
.bytes(r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#.into())
.await;
resp.assert_status(axum::http::StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn mcp_tools_call_unknown_tool() {
let server = test_app();
let resp = server
.post("/mcp")
.content_type("application/json")
.json(&mcp_request(
"tools/call",
json!(5),
json!({"name": "nonexistent_tool", "arguments": {}}),
))
.await;
resp.assert_status_ok();
let json: serde_json::Value = resp.json();
let result = &json["result"];
assert_eq!(result["isError"], true);
}
#[tokio::test]
async fn mcp_null_id() {
let server = test_app();
let resp = server
.post("/mcp")
.content_type("application/json")
.json(&mcp_request("ping", json!(null), json!({})))
.await;
resp.assert_status_ok();
let json: serde_json::Value = resp.json();
assert!(json["id"].is_null());
}
#[tokio::test]
async fn mcp_integer_id() {
let server = test_app();
let resp = server
.post("/mcp")
.content_type("application/json")
.json(&mcp_request("ping", json!(42), json!({})))
.await;
resp.assert_status_ok();
let json: serde_json::Value = resp.json();
assert_eq!(json["id"], 42);
}
#[tokio::test]
async fn mcp_crw_scrape_advertises_renderer_in_tools_list() {
let server = test_app();
let resp = server
.post("/mcp")
.content_type("application/json")
.json(&mcp_request("tools/list", json!(99), json!({})))
.await;
resp.assert_status_ok();
let json: serde_json::Value = resp.json();
let tools = json["result"]["tools"].as_array().unwrap();
let scrape = tools
.iter()
.find(|t| t["name"] == "crw_scrape")
.expect("crw_scrape tool");
let renderer = &scrape["inputSchema"]["properties"]["renderer"];
assert_eq!(renderer["type"], "string");
let enum_vals = renderer["enum"].as_array().expect("renderer.enum");
assert_eq!(enum_vals.len(), 4);
for v in ["auto", "lightpanda", "chrome", "playwright"] {
assert!(
enum_vals.iter().any(|e| e == v),
"renderer enum missing {v}"
);
}
}
#[tokio::test]
async fn mcp_crw_crawl_renderer_unavailable_returns_tool_error() {
let server = test_app();
let resp = server
.post("/mcp")
.content_type("application/json")
.json(&mcp_request(
"tools/call",
json!(100),
json!({
"name": "crw_crawl",
"arguments": {"url": "https://example.com", "renderer": "chrome"}
}),
))
.await;
resp.assert_status_ok();
let json: serde_json::Value = resp.json();
let result = &json["result"];
assert_eq!(result["isError"], true);
let text = result["content"][0]["text"].as_str().unwrap();
assert!(
text.contains("renderer 'chrome' not available"),
"expected pinned-renderer error in MCP tool error, got: {text}"
);
}
#[tokio::test]
async fn mcp_missing_method_field() {
let server = test_app();
let body = json!({
"jsonrpc": "2.0",
"id": 1,
"params": {}
});
let resp = server
.post("/mcp")
.content_type("application/json")
.json(&body)
.await;
resp.assert_status_ok();
let json: serde_json::Value = resp.json();
assert_eq!(json["error"]["code"], -32700);
}
#[tokio::test]
async fn mcp_t8_search_advertises_nested_output_schema() {
let server = test_app();
let resp = server
.post("/mcp")
.content_type("application/json")
.json(&mcp_request("tools/list", json!(8), json!({})))
.await;
resp.assert_status_ok();
let json: serde_json::Value = resp.json();
let tools = json["result"]["tools"].as_array().unwrap();
let search = tools
.iter()
.find(|t| t["name"] == "crw_search")
.expect("crw_search tool");
let schema = &search["outputSchema"];
assert_eq!(schema["type"], "object");
assert_eq!(schema["properties"]["data"]["type"], "object");
let data_required = schema["properties"]["data"]["required"]
.as_array()
.expect("data.required");
assert!(data_required.iter().any(|v| v == "results"));
}
#[tokio::test]
async fn mcp_t9_search_disabled_is_tool_error() {
let server = test_app();
let resp = server
.post("/mcp")
.content_type("application/json")
.json(&mcp_request(
"tools/call",
json!(9),
json!({"name": "crw_search", "arguments": {"query": "anything"}}),
))
.await;
resp.assert_status_ok();
let json: serde_json::Value = resp.json();
let result = &json["result"];
assert_eq!(result["isError"], true);
assert!(result["content"][0]["text"].is_string());
assert!(result.get("structuredContent").is_none());
}
#[tokio::test]
async fn mcp_t10_protocol_version_header_tolerated() {
let server = test_app();
let name = HeaderName::from_static("mcp-protocol-version");
for header in [
Some("2025-06-18"),
Some("2024-11-05"),
Some("not-a-version"),
None,
] {
let mut req = server.post("/mcp").content_type("application/json");
if let Some(v) = header {
req = req.add_header(name.clone(), HeaderValue::from_static(v));
}
let resp = req.json(&mcp_request("ping", json!(10), json!({}))).await;
resp.assert_status_ok();
let json: serde_json::Value = resp.json();
assert_eq!(
json["result"],
json!({}),
"ping must succeed regardless of MCP-Protocol-Version header = {header:?}"
);
}
}
fn real_search_result(idx: u32) -> SearchResult {
SearchResult {
url: format!("https://example.com/{idx}"),
title: format!("Result {idx}"),
description: "body text".into(),
snippet: "body text".into(),
position: idx,
score: Some(4.0),
published_date: None,
category: Some("general".into()),
markdown: None,
html: None,
raw_html: None,
links: None,
metadata: None,
summary: None,
}
}
fn real_image_result(idx: u32) -> ImageResult {
ImageResult {
url: format!("https://example.com/img/{idx}"),
title: format!("Image {idx}"),
description: "alt text".into(),
image_url: format!("https://example.com/img/{idx}.png"),
position: idx,
thumbnail_url: None,
image_format: None,
resolution: None,
}
}
fn envelope(results: SearchData) -> SearchResponseData {
SearchResponseData {
results,
answer: None,
citations: Vec::new(),
llm_usage: None,
warnings: Vec::new(),
}
}
fn structured_content_for(value: Value) -> Value {
let resp = tool_result_response(json!(1), "crw_search", Ok(value));
resp.result
.expect("success response")
.get("structuredContent")
.cloned()
.expect("crw_search emits structuredContent for an object value")
}
#[tokio::test]
async fn mcp_t12_real_serializer_validates_against_output_schema() {
let schema = tool_output_schema("crw_search").expect("crw_search outputSchema");
let validator = jsonschema::validator_for(&schema).expect("schema compiles");
let cases: Vec<(&str, SearchData)> = vec![
(
"A: flat populated",
SearchData::Flat(vec![real_search_result(1), real_search_result(2)]),
),
("B: flat empty", SearchData::Flat(vec![])),
(
"C: grouped web+news+images",
SearchData::Grouped(GroupedSearchData {
web: Some(vec![real_search_result(1)]),
news: Some(vec![real_search_result(2)]),
images: Some(vec![real_image_result(1)]),
}),
),
(
"D: grouped empty",
SearchData::Grouped(GroupedSearchData::default()),
),
];
for (label, results) in cases {
let is_empty_grouped = matches!(&results, SearchData::Grouped(g) if g.web.is_none() && g.news.is_none() && g.images.is_none());
let response = ApiResponse::ok(envelope(results));
let value = serde_json::to_value(&response).expect("serialize SearchResponse");
if is_empty_grouped {
assert_eq!(
value["data"]["results"],
json!({}),
"empty grouped results must serialize to an empty object"
);
}
let structured = structured_content_for(value.clone());
let errors: Vec<String> = validator
.iter_errors(&structured)
.map(|e| e.to_string())
.collect();
assert!(
errors.is_empty(),
"[{label}] real serializer output failed schema validation:\n{}\nvalue: {value}",
errors.join("\n")
);
}
}