use std::time::Duration;
use kagi_sdk::ClientConfig;
use mockito::{Matcher, Server};
use rmcp::{
model::{CallToolRequestParams, ErrorCode},
ServiceError, ServiceExt,
};
use serde_json::{json, Value};
use url::Url;
use crate::{
backend::{BackendRuntime, EnvConfig, ENV_API_KEY, ENV_BACKEND_MODE, ENV_SESSION_TOKEN},
KagiMcpServer,
};
fn build_config(server: &Server) -> ClientConfig {
ClientConfig::default()
.with_base_url(Url::parse(&server.url()).expect("mock server URL is always valid"))
}
fn build_official_backend(server: &Server) -> BackendRuntime {
BackendRuntime::from_env_config(
EnvConfig {
backend_mode: Some("official".to_string()),
api_key: Some("official_token".to_string()),
session_token: Some("session_token".to_string()),
},
build_config(server),
)
.expect("official backend should build")
}
fn build_session_backend(server: &Server) -> BackendRuntime {
BackendRuntime::from_env_config(
EnvConfig {
backend_mode: Some("session".to_string()),
api_key: Some("official_token".to_string()),
session_token: Some("session_token".to_string()),
},
build_config(server),
)
.expect("session backend should build")
}
async fn start_server(
backend: BackendRuntime,
) -> (
rmcp::service::RunningService<rmcp::RoleClient, ()>,
tokio::task::JoinHandle<()>,
) {
let (server_transport, client_transport) = tokio::io::duplex(64 * 1024);
let server = KagiMcpServer::from_backend(backend).expect("server should construct");
let handle = tokio::spawn(async move {
let running = server
.serve(server_transport)
.await
.expect("server should start");
let _ = running.waiting().await.expect("server should stop cleanly");
});
let client = ().serve(client_transport).await.expect("client should connect");
(client, handle)
}
fn json_object(value: Value) -> serde_json::Map<String, Value> {
value
.as_object()
.cloned()
.expect("arguments should be a JSON object")
}
fn assert_invalid_params(error: ServiceError) {
match error {
ServiceError::McpError(data) => {
assert_eq!(data.code, ErrorCode::INVALID_PARAMS);
}
unexpected => panic!("expected invalid params mcp error, got {unexpected:?}"),
}
}
fn schema_property<'a>(schema: &'a Value, field: &str, context: &str) -> &'a Value {
schema
.get("properties")
.and_then(Value::as_object)
.and_then(|properties| properties.get(field))
.unwrap_or_else(|| panic!("{context} should expose `{field}` property"))
}
fn schema_property_names(schema: &Value, context: &str) -> Vec<String> {
let mut names = schema
.get("properties")
.and_then(Value::as_object)
.unwrap_or_else(|| panic!("{context} should expose properties"))
.keys()
.cloned()
.collect::<Vec<_>>();
names.sort();
names
}
fn schema_required_fields(schema: &Value, context: &str) -> Vec<String> {
let mut required = schema
.get("required")
.and_then(Value::as_array)
.unwrap_or_else(|| panic!("{context} should expose required fields"))
.iter()
.map(|field| {
field
.as_str()
.unwrap_or_else(|| panic!("{context} required field should be string"))
.to_string()
})
.collect::<Vec<_>>();
required.sort();
required
}
fn assert_additional_properties_false(schema: &Value, context: &str) {
assert_eq!(
schema.get("additionalProperties"),
Some(&Value::Bool(false)),
"{context} must disallow unknown fields"
);
}
fn resolve_local_schema_ref<'a>(
root: &'a Value,
mut schema: &'a Value,
context: &str,
) -> &'a Value {
loop {
let Some(reference) = schema.get("$ref").and_then(Value::as_str) else {
return schema;
};
let pointer = reference
.strip_prefix('#')
.unwrap_or_else(|| panic!("{context} must use a local JSON pointer reference"));
schema = root
.pointer(pointer)
.unwrap_or_else(|| panic!("{context} points to missing schema ref `{reference}`"));
}
}
fn search_result_html(count: usize) -> String {
let mut html = String::from("<html><body>");
for index in 0..count {
html.push_str(&format!(
r#"<div class="search-result"><a class="__sri_title_link" href="https://example.com/{index}">Result {index}</a><div class="__sri-desc">Snippet {index}</div></div>"#
));
}
html.push_str("</body></html>");
html
}
#[tokio::test]
async fn tool_listing_has_exactly_two_tools_with_read_only_idempotent_metadata() {
let server = Server::new();
let backend = build_official_backend(&server);
let (client, handle) = start_server(backend).await;
let tools = client
.peer()
.list_all_tools()
.await
.expect("tools list should succeed");
let mut names = tools
.iter()
.map(|tool| tool.name.to_string())
.collect::<Vec<_>>();
names.sort();
assert_eq!(names, vec!["kagi_search", "kagi_summarize"]);
for tool in tools {
let annotations = tool.annotations.expect("tool annotations are required");
assert_eq!(annotations.read_only_hint, Some(true));
assert_eq!(annotations.idempotent_hint, Some(true));
}
let prompts = client
.peer()
.list_all_prompts()
.await
.expect("prompt list should succeed");
assert!(prompts.is_empty());
let resources = client
.peer()
.list_all_resources()
.await
.expect("resource list should succeed");
assert!(resources.is_empty());
client
.cancel()
.await
.expect("client shutdown should succeed");
handle.await.expect("server join should succeed");
}
#[tokio::test]
async fn tool_schemas_publish_strict_v1_contract() {
let server = Server::new();
let backend = build_official_backend(&server);
let (client, handle) = start_server(backend).await;
let tools = client
.peer()
.list_all_tools()
.await
.expect("tools list should succeed");
let search_tool = tools
.iter()
.find(|tool| tool.name == "kagi_search")
.expect("kagi_search tool should exist");
let search_input_schema = Value::Object(search_tool.input_schema.as_ref().clone());
assert_eq!(search_input_schema.get("type"), Some(&json!("object")));
assert_additional_properties_false(&search_input_schema, "search input schema");
assert_eq!(
schema_property_names(&search_input_schema, "search input schema"),
vec!["limit", "query"]
);
assert_eq!(
schema_required_fields(&search_input_schema, "search input schema"),
vec!["query"]
);
let search_query_schema = schema_property(&search_input_schema, "query", "search input schema");
assert_eq!(search_query_schema.get("type"), Some(&json!("string")));
assert_eq!(search_query_schema.get("minLength"), Some(&json!(1)));
assert_eq!(search_query_schema.get("pattern"), Some(&json!(".*\\S.*")));
let search_limit_schema = schema_property(&search_input_schema, "limit", "search input schema");
assert_eq!(search_limit_schema.get("type"), Some(&json!("integer")));
assert_eq!(search_limit_schema.get("minimum"), Some(&json!(1)));
assert_eq!(search_limit_schema.get("maximum"), Some(&json!(10)));
assert_eq!(search_limit_schema.get("default"), Some(&json!(5)));
let search_output_root = Value::Object(
search_tool
.output_schema
.as_ref()
.expect("search output schema should exist")
.as_ref()
.clone(),
);
let search_output_schema = resolve_local_schema_ref(
&search_output_root,
&search_output_root,
"search output root schema",
);
assert_eq!(search_output_schema.get("type"), Some(&json!("object")));
assert_additional_properties_false(search_output_schema, "search output schema");
assert_eq!(
schema_property_names(search_output_schema, "search output schema"),
vec!["results", "total_returned"]
);
assert_eq!(
schema_required_fields(search_output_schema, "search output schema"),
vec!["results", "total_returned"]
);
let total_returned_schema = schema_property(
search_output_schema,
"total_returned",
"search output schema",
);
assert_eq!(
total_returned_schema,
&json!({
"type": "integer",
"minimum": 0
})
);
assert_eq!(
total_returned_schema.get("format"),
None,
"total_returned schema must not publish unsupported integer formats"
);
let search_results_schema =
schema_property(search_output_schema, "results", "search output schema");
assert_eq!(search_results_schema.get("type"), Some(&json!("array")));
let search_result_card_schema = resolve_local_schema_ref(
&search_output_root,
search_results_schema
.get("items")
.expect("search results should include item schema"),
"search result card schema",
);
assert_eq!(
search_result_card_schema.get("type"),
Some(&json!("object"))
);
assert_additional_properties_false(search_result_card_schema, "search result card schema");
assert_eq!(
schema_property_names(search_result_card_schema, "search result card schema"),
vec!["snippet", "title", "url"]
);
assert_eq!(
schema_required_fields(search_result_card_schema, "search result card schema"),
vec!["title", "url"]
);
let summarize_tool = tools
.iter()
.find(|tool| tool.name == "kagi_summarize")
.expect("kagi_summarize tool should exist");
let summarize_input_schema = Value::Object(summarize_tool.input_schema.as_ref().clone());
assert_eq!(summarize_input_schema.get("type"), Some(&json!("object")));
assert_additional_properties_false(&summarize_input_schema, "summarize input schema");
assert_eq!(
schema_property_names(&summarize_input_schema, "summarize input schema"),
vec!["text", "url"]
);
assert_eq!(
summarize_input_schema.get("required"),
None,
"summarize input schema should not require top-level fields"
);
for forbidden_keyword in [
"oneOf",
"anyOf",
"allOf",
"not",
"minProperties",
"maxProperties",
] {
assert_eq!(
summarize_input_schema.get(forbidden_keyword),
None,
"summarize input schema must not publish `{forbidden_keyword}`"
);
}
let summarize_url_property =
schema_property(&summarize_input_schema, "url", "summarize input schema");
assert_eq!(summarize_url_property.get("type"), Some(&json!("string")));
for forbidden_keyword in ["format", "pattern", "minLength", "maxLength"] {
assert_eq!(
summarize_url_property.get(forbidden_keyword),
None,
"summarize url property must not publish `{forbidden_keyword}`"
);
}
let summarize_text_property =
schema_property(&summarize_input_schema, "text", "summarize input schema");
assert_eq!(summarize_text_property.get("type"), Some(&json!("string")));
for forbidden_keyword in ["format", "pattern", "minLength", "maxLength"] {
assert_eq!(
summarize_text_property.get(forbidden_keyword),
None,
"summarize text property must not publish `{forbidden_keyword}`"
);
}
let summarize_output_root = Value::Object(
summarize_tool
.output_schema
.as_ref()
.expect("summarize output schema should exist")
.as_ref()
.clone(),
);
let summarize_output_schema = resolve_local_schema_ref(
&summarize_output_root,
&summarize_output_root,
"summarize output root schema",
);
assert_eq!(summarize_output_schema.get("type"), Some(&json!("object")));
assert_additional_properties_false(summarize_output_schema, "summarize output schema");
assert_eq!(
schema_property_names(summarize_output_schema, "summarize output schema"),
vec!["markdown", "source_url", "text"]
);
assert_eq!(
schema_required_fields(summarize_output_schema, "summarize output schema"),
vec!["markdown"]
);
client
.cancel()
.await
.expect("client shutdown should succeed");
handle.await.expect("server join should succeed");
}
#[tokio::test]
async fn official_backend_parity_matrix_search_and_summarize_modes() {
let mut server = Server::new();
let _search_mock = server
.mock("GET", "/api/v0/search")
.match_query(Matcher::UrlEncoded("q".into(), "rust sdk".into()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"data":{"results":[{"title":"One","url":"https://one.test","snippet":"alpha","unused":"x"},{"title":"Two","url":"https://two.test"}]}}"#,
)
.create();
let _summarize_get_mock = server
.mock("GET", "/api/v0/summarize")
.match_query(Matcher::UrlEncoded(
"url".into(),
"https://example.com/post".into(),
))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
json!({
"data": {
"markdown": "# Official URL",
"text": "url summary",
"source_url": "https://example.com/post",
"metadata": { "ignored": true }
}
})
.to_string(),
)
.create();
let _summarize_post_mock = server
.mock("POST", "/api/v0/summarize")
.match_body(Matcher::JsonString(
json!({ "text": " keep exact spacing " }).to_string(),
))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
json!({
"data": {
"markdown": "# Official Text",
"text": " keep exact spacing "
}
})
.to_string(),
)
.create();
let backend = build_official_backend(&server);
let (client, handle) = start_server(backend).await;
let search_result = client
.call_tool(
CallToolRequestParams::new("kagi_search").with_arguments(json_object(json!({
"query": " rust sdk ",
"limit": 2
}))),
)
.await
.expect("official search should succeed");
assert_eq!(search_result.is_error, Some(false));
let search_output: crate::SearchToolOutput = search_result
.into_typed()
.expect("search output should deserialize");
assert_eq!(search_output.total_returned, 2);
assert_eq!(search_output.results[0].title, "One");
assert_eq!(search_output.results[1].url, "https://two.test");
let summarize_url_result = client
.call_tool(
CallToolRequestParams::new("kagi_summarize").with_arguments(json_object(json!({
"url": "https://example.com/post"
}))),
)
.await
.expect("official summarize(url) should succeed");
let summarize_url_output: crate::SummarizeToolOutput = summarize_url_result
.into_typed()
.expect("summarize(url) output should deserialize");
assert_eq!(summarize_url_output.markdown, "# Official URL");
assert_eq!(
summarize_url_output.source_url.as_deref(),
Some("https://example.com/post")
);
let summarize_text_result = client
.call_tool(
CallToolRequestParams::new("kagi_summarize").with_arguments(json_object(json!({
"text": " keep exact spacing "
}))),
)
.await
.expect("official summarize(text) should succeed");
let summarize_text_output: crate::SummarizeToolOutput = summarize_text_result
.into_typed()
.expect("summarize(text) output should deserialize");
assert_eq!(summarize_text_output.markdown, "# Official Text");
assert_eq!(
summarize_text_output.text.as_deref(),
Some(" keep exact spacing ")
);
client
.cancel()
.await
.expect("client shutdown should succeed");
handle.await.expect("server join should succeed");
}
#[tokio::test]
async fn session_backend_parity_matrix_search_and_summarize_modes() {
let mut server = Server::new();
let _search_mock = server
.mock("GET", "/html/search")
.match_query(Matcher::UrlEncoded("q".into(), "rust sdk".into()))
.with_status(200)
.with_header("content-type", "text/html")
.with_body(search_result_html(3))
.create();
let _summarize_url_mock = server
.mock("GET", "/mother/summary_labs")
.match_query(Matcher::UrlEncoded(
"url".into(),
"https://example.com/post".into(),
))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
json!({
"markdown": "# Session URL",
"text": "session url",
"metadata": {
"source_url": "https://example.com/post",
"tokens": 42
}
})
.to_string(),
)
.create();
let _summarize_text_mock = server
.mock("POST", "/mother/summary_labs/")
.match_body(Matcher::UrlEncoded(
"text".to_string(),
" keep exact spacing ".to_string(),
))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
json!({
"markdown": "# Session Text",
"text": " keep exact spacing "
})
.to_string(),
)
.create();
let backend = build_session_backend(&server);
let (client, handle) = start_server(backend).await;
let search_result = client
.call_tool(
CallToolRequestParams::new("kagi_search").with_arguments(json_object(json!({
"query": " rust sdk ",
"limit": 2
}))),
)
.await
.expect("session search should succeed");
let search_output: crate::SearchToolOutput = search_result
.into_typed()
.expect("search output should deserialize");
assert_eq!(search_output.total_returned, 2);
assert_eq!(search_output.results[0].title, "Result 0");
let summarize_url_result = client
.call_tool(
CallToolRequestParams::new("kagi_summarize").with_arguments(json_object(json!({
"url": "https://example.com/post"
}))),
)
.await
.expect("session summarize(url) should succeed");
let summarize_url_output: crate::SummarizeToolOutput = summarize_url_result
.into_typed()
.expect("summarize(url) output should deserialize");
assert_eq!(summarize_url_output.markdown, "# Session URL");
assert_eq!(
summarize_url_output.source_url.as_deref(),
Some("https://example.com/post")
);
let summarize_text_result = client
.call_tool(
CallToolRequestParams::new("kagi_summarize").with_arguments(json_object(json!({
"text": " keep exact spacing "
}))),
)
.await
.expect("session summarize(text) should succeed");
let summarize_text_output: crate::SummarizeToolOutput = summarize_text_result
.into_typed()
.expect("summarize(text) output should deserialize");
assert_eq!(summarize_text_output.markdown, "# Session Text");
assert_eq!(
summarize_text_output.text.as_deref(),
Some(" keep exact spacing ")
);
client
.cancel()
.await
.expect("client shutdown should succeed");
handle.await.expect("server join should succeed");
}
#[tokio::test]
async fn search_validation_rules_and_limit_behavior_are_enforced() {
let mut server = Server::new();
let _search_mock = server
.mock("GET", "/api/v0/search")
.match_query(Matcher::UrlEncoded("q".into(), "rust".into()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"data":{"results":[{"title":"1","url":"https://1.test"},{"title":"2","url":"https://2.test"},{"title":"3","url":"https://3.test"},{"title":"4","url":"https://4.test"},{"title":"5","url":"https://5.test"},{"title":"6","url":"https://6.test"}]}}"#,
)
.create();
let backend = build_official_backend(&server);
let (client, handle) = start_server(backend).await;
let missing_query = client
.call_tool(
CallToolRequestParams::new("kagi_search").with_arguments(json_object(json!({
"limit": 3
}))),
)
.await
.expect_err("missing query must fail");
assert_invalid_params(missing_query);
let blank_query = client
.call_tool(
CallToolRequestParams::new("kagi_search").with_arguments(json_object(json!({
"query": " "
}))),
)
.await
.expect_err("blank query must fail");
assert_invalid_params(blank_query);
let invalid_limit_low = client
.call_tool(
CallToolRequestParams::new("kagi_search").with_arguments(json_object(json!({
"query": "rust",
"limit": 0
}))),
)
.await
.expect_err("limit below min must fail");
assert_invalid_params(invalid_limit_low);
let invalid_limit_high = client
.call_tool(
CallToolRequestParams::new("kagi_search").with_arguments(json_object(json!({
"query": "rust",
"limit": 11
}))),
)
.await
.expect_err("limit above max must fail");
assert_invalid_params(invalid_limit_high);
let unknown_field = client
.call_tool(
CallToolRequestParams::new("kagi_search").with_arguments(json_object(json!({
"query": "rust",
"extra": true
}))),
)
.await
.expect_err("unknown field must fail");
assert_invalid_params(unknown_field);
let default_limit_result = client
.call_tool(
CallToolRequestParams::new("kagi_search").with_arguments(json_object(json!({
"query": " rust "
}))),
)
.await
.expect("search with default limit should succeed");
let default_limit_output: crate::SearchToolOutput = default_limit_result
.into_typed()
.expect("default limit output should deserialize");
assert_eq!(default_limit_output.total_returned, 5);
let explicit_limit_result = client
.call_tool(
CallToolRequestParams::new("kagi_search").with_arguments(json_object(json!({
"query": "rust",
"limit": 1
}))),
)
.await
.expect("search with explicit limit should succeed");
let explicit_limit_output: crate::SearchToolOutput = explicit_limit_result
.into_typed()
.expect("explicit limit output should deserialize");
assert_eq!(explicit_limit_output.total_returned, 1);
client
.cancel()
.await
.expect("client shutdown should succeed");
handle.await.expect("server join should succeed");
}
#[tokio::test]
async fn summarize_validation_rules_and_byte_limits_are_enforced() {
let mut server = Server::new();
let _summarize_url_mock = server
.mock("GET", "/api/v0/summarize")
.match_query(Matcher::UrlEncoded(
"url".into(),
"https://example.com/post".into(),
))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(json!({ "data": { "markdown": "# URL" } }).to_string())
.create();
let _summarize_text_mock = server
.mock("POST", "/api/v0/summarize")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(json!({ "data": { "markdown": "# TEXT" } }).to_string())
.create();
let backend = build_official_backend(&server);
let (client, handle) = start_server(backend).await;
let missing_both = client
.call_tool(
CallToolRequestParams::new("kagi_summarize").with_arguments(json_object(json!({}))),
)
.await
.expect_err("missing both url/text must fail");
assert_invalid_params(missing_both);
let url_with_empty_text = client
.call_tool(
CallToolRequestParams::new("kagi_summarize").with_arguments(json_object(json!({
"url": "https://example.com/post",
"text": ""
}))),
)
.await
.expect("url should succeed when text is exact empty string");
let url_with_empty_text_output: crate::SummarizeToolOutput = url_with_empty_text
.into_typed()
.expect("url+empty text output should deserialize");
assert_eq!(url_with_empty_text_output.markdown, "# URL");
let text_with_empty_url = client
.call_tool(
CallToolRequestParams::new("kagi_summarize").with_arguments(json_object(json!({
"text": "real text",
"url": ""
}))),
)
.await
.expect("text should succeed when url is exact empty string");
let text_with_empty_url_output: crate::SummarizeToolOutput = text_with_empty_url
.into_typed()
.expect("text+empty url output should deserialize");
assert_eq!(text_with_empty_url_output.markdown, "# TEXT");
let both_fields = client
.call_tool(
CallToolRequestParams::new("kagi_summarize").with_arguments(json_object(json!({
"url": "https://example.com/post",
"text": "hello"
}))),
)
.await
.expect_err("url+text together must fail");
assert_invalid_params(both_fields);
let both_empty_fields = client
.call_tool(
CallToolRequestParams::new("kagi_summarize").with_arguments(json_object(json!({
"url": "",
"text": ""
}))),
)
.await
.expect_err("url+text exact empty strings must fail");
assert_invalid_params(both_empty_fields);
let url_with_whitespace_only_text = client
.call_tool(
CallToolRequestParams::new("kagi_summarize").with_arguments(json_object(json!({
"url": "https://example.com/post",
"text": " "
}))),
)
.await
.expect_err("url + whitespace-only text must fail");
assert_invalid_params(url_with_whitespace_only_text);
let text_with_whitespace_padded_url = client
.call_tool(
CallToolRequestParams::new("kagi_summarize").with_arguments(json_object(json!({
"text": "real text",
"url": " https://example.com/post"
}))),
)
.await
.expect_err("text + whitespace-padded url must fail");
assert_invalid_params(text_with_whitespace_padded_url);
let text_with_whitespace_only_url = client
.call_tool(
CallToolRequestParams::new("kagi_summarize").with_arguments(json_object(json!({
"text": "real text",
"url": " "
}))),
)
.await
.expect_err("text + whitespace-only url must fail");
assert_invalid_params(text_with_whitespace_only_url);
let invalid_url = client
.call_tool(
CallToolRequestParams::new("kagi_summarize").with_arguments(json_object(json!({
"url": "ftp://example.com/post"
}))),
)
.await
.expect_err("non-http url must fail");
assert_invalid_params(invalid_url);
let padded_url = client
.call_tool(
CallToolRequestParams::new("kagi_summarize").with_arguments(json_object(json!({
"url": " https://example.com/post "
}))),
)
.await
.expect_err("whitespace-padded url must fail");
assert_invalid_params(padded_url);
let blank_text = client
.call_tool(
CallToolRequestParams::new("kagi_summarize").with_arguments(json_object(json!({
"text": " "
}))),
)
.await
.expect_err("blank text must fail");
assert_invalid_params(blank_text);
let unknown_field = client
.call_tool(
CallToolRequestParams::new("kagi_summarize").with_arguments(json_object(json!({
"url": "https://example.com/post",
"backend": "official"
}))),
)
.await
.expect_err("unknown summarize field must fail");
assert_invalid_params(unknown_field);
let accepted_text = "a".repeat(50_000);
client
.call_tool(
CallToolRequestParams::new("kagi_summarize").with_arguments(json_object(json!({
"text": accepted_text
}))),
)
.await
.expect("50_000 byte summarize text should be accepted");
let rejected_text = "a".repeat(50_001);
let rejected_result = client
.call_tool(
CallToolRequestParams::new("kagi_summarize").with_arguments(json_object(json!({
"text": rejected_text
}))),
)
.await
.expect_err("50_001 byte summarize text should be rejected");
assert_invalid_params(rejected_result);
let accepted_multibyte_text = "é".repeat(25_000);
client
.call_tool(
CallToolRequestParams::new("kagi_summarize").with_arguments(json_object(json!({
"text": accepted_multibyte_text
}))),
)
.await
.expect("50_000-byte multibyte summarize text should be accepted");
let rejected_multibyte_text = "é".repeat(25_001);
let rejected_multibyte_result = client
.call_tool(
CallToolRequestParams::new("kagi_summarize").with_arguments(json_object(json!({
"text": rejected_multibyte_text
}))),
)
.await
.expect_err("50_002-byte multibyte summarize text should be rejected");
assert_invalid_params(rejected_multibyte_result);
client
.cancel()
.await
.expect("client shutdown should succeed");
handle.await.expect("server join should succeed");
}
#[tokio::test]
async fn outputs_never_leak_provider_envelopes_or_metadata_and_do_not_truncate() {
let mut server = Server::new();
let long_markdown = "x".repeat(70_000);
let _search_mock = server
.mock("GET", "/api/v0/search")
.match_query(Matcher::UrlEncoded("q".into(), "rust".into()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"data":{"results":[{"title":"One","url":"https://one.test","snippet":"alpha","provider_meta":{"rank":1}}],"extra":{"ignored":true}}}"#,
)
.create();
let _summarize_mock = server
.mock("GET", "/api/v0/summarize")
.match_query(Matcher::UrlEncoded(
"url".into(),
"https://example.com/post".into(),
))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
json!({
"data": {
"markdown": long_markdown,
"text": "plain",
"metadata": {"tokens": 999},
"status": "ok"
}
})
.to_string(),
)
.create();
let backend = build_official_backend(&server);
let (client, handle) = start_server(backend).await;
let search_result = client
.call_tool(
CallToolRequestParams::new("kagi_search").with_arguments(json_object(json!({
"query": "rust",
"limit": 5
}))),
)
.await
.expect("search should succeed");
let search_output: crate::SearchToolOutput = search_result
.into_typed()
.expect("search output should deserialize");
let serialized_search =
serde_json::to_value(search_output).expect("search output should serialize");
let mut search_keys = serialized_search
.as_object()
.expect("search output should be object")
.keys()
.cloned()
.collect::<Vec<_>>();
search_keys.sort();
assert_eq!(search_keys, vec!["results", "total_returned"]);
let summarize_result = client
.call_tool(
CallToolRequestParams::new("kagi_summarize").with_arguments(json_object(json!({
"url": "https://example.com/post"
}))),
)
.await
.expect("summarize should succeed");
let summarize_output: crate::SummarizeToolOutput = summarize_result
.into_typed()
.expect("summarize output should deserialize");
assert_eq!(summarize_output.markdown.len(), 70_000);
let serialized_summarize =
serde_json::to_value(summarize_output).expect("summarize output should serialize");
let mut summarize_keys = serialized_summarize
.as_object()
.expect("summarize output should be object")
.keys()
.cloned()
.collect::<Vec<_>>();
summarize_keys.sort();
assert_eq!(summarize_keys, vec!["markdown", "source_url", "text"]);
client
.cancel()
.await
.expect("client shutdown should succeed");
handle.await.expect("server join should succeed");
}
#[tokio::test]
async fn tool_errors_are_mapped_for_auth_upstream_parse_and_transport_failures() {
let mut auth_server = Server::new();
let _auth_mock = auth_server
.mock("GET", "/api/v0/search")
.match_query(Matcher::UrlEncoded("q".into(), "rust".into()))
.with_status(401)
.with_body("bad token secret_auth_token")
.create();
let auth_backend = build_official_backend(&auth_server);
let (auth_client, auth_handle) = start_server(auth_backend).await;
let auth_result = auth_client
.call_tool(
CallToolRequestParams::new("kagi_search").with_arguments(json_object(json!({
"query": "rust"
}))),
)
.await
.expect("auth failure should still return tool result");
assert_eq!(auth_result.is_error, Some(true));
let auth_text = auth_result
.content
.first()
.and_then(|content| content.as_text())
.map(|content| content.text.clone())
.expect("auth failure should return text error");
assert!(auth_text.contains("Authentication failed"));
assert!(auth_text.contains(ENV_API_KEY));
assert!(auth_text.contains(ENV_SESSION_TOKEN));
assert!(!auth_text.contains("secret_auth_token"));
auth_client
.cancel()
.await
.expect("client shutdown should succeed");
auth_handle.await.expect("server join should succeed");
let mut upstream_server = Server::new();
let _upstream_mock = upstream_server
.mock("GET", "/api/v0/search")
.match_query(Matcher::UrlEncoded("q".into(), "rust".into()))
.with_status(429)
.with_header("content-type", "application/json")
.with_body(r#"{"error":"rate limited"}"#)
.create();
let upstream_backend = build_official_backend(&upstream_server);
let (upstream_client, upstream_handle) = start_server(upstream_backend).await;
let upstream_result = upstream_client
.call_tool(
CallToolRequestParams::new("kagi_search").with_arguments(json_object(json!({
"query": "rust"
}))),
)
.await
.expect("upstream failure should still return tool result");
let upstream_text = upstream_result
.content
.first()
.and_then(|content| content.as_text())
.map(|content| content.text.clone())
.expect("upstream failure should return text error");
assert!(upstream_text.contains("HTTP 429"));
upstream_client
.cancel()
.await
.expect("client shutdown should succeed");
upstream_handle.await.expect("server join should succeed");
let mut parse_server = Server::new();
let _parse_mock = parse_server
.mock("GET", "/api/v0/search")
.match_query(Matcher::UrlEncoded("q".into(), "rust".into()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"data":{"unexpected":[]}}"#)
.create();
let parse_backend = build_official_backend(&parse_server);
let (parse_client, parse_handle) = start_server(parse_backend).await;
let parse_result = parse_client
.call_tool(
CallToolRequestParams::new("kagi_search").with_arguments(json_object(json!({
"query": "rust"
}))),
)
.await
.expect("parse drift should still return tool result");
let parse_text = parse_result
.content
.first()
.and_then(|content| content.as_text())
.map(|content| content.text.clone())
.expect("parse drift should return text error");
assert!(parse_text.contains("unexpected response shape"));
parse_client
.cancel()
.await
.expect("client shutdown should succeed");
parse_handle.await.expect("server join should succeed");
let transport_backend = BackendRuntime::from_env_config(
EnvConfig {
backend_mode: Some("official".to_string()),
api_key: Some("official_token".to_string()),
session_token: None,
},
ClientConfig::default()
.with_base_url(Url::parse("http://127.0.0.1:9").expect("url should parse"))
.with_timeout(Duration::from_millis(50)),
)
.expect("transport backend should build");
let (transport_client, transport_handle) = start_server(transport_backend).await;
let transport_result = transport_client
.call_tool(
CallToolRequestParams::new("kagi_search").with_arguments(json_object(json!({
"query": "rust"
}))),
)
.await
.expect("transport failure should still return tool result");
let transport_text = transport_result
.content
.first()
.and_then(|content| content.as_text())
.map(|content| content.text.clone())
.expect("transport failure should return text error");
assert!(transport_text.contains("transport") || transport_text.contains("timed out"));
transport_client
.cancel()
.await
.expect("client shutdown should succeed");
transport_handle.await.expect("server join should succeed");
}
#[tokio::test]
async fn session_summarize_http_200_auth_like_failures_map_to_auth_message() {
for unauthorized_payload in [
r#"{"error":"Unauthorized"}"#,
r#"{"success":false,"message":"Unauthorized: Unauthorized"}"#,
] {
let mut server = Server::new();
let _summarize_mock = server
.mock("GET", "/mother/summary_labs")
.match_query(Matcher::UrlEncoded(
"url".into(),
"https://example.com/protected".into(),
))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(unauthorized_payload)
.create();
let backend = build_session_backend(&server);
let (client, handle) = start_server(backend).await;
let summarize_result = client
.call_tool(
CallToolRequestParams::new("kagi_summarize").with_arguments(json_object(json!({
"url": "https://example.com/protected"
}))),
)
.await
.expect("auth-like summarize failure should still return tool result");
assert_eq!(summarize_result.is_error, Some(true));
let summarize_error_text = summarize_result
.content
.first()
.and_then(|content| content.as_text())
.map(|content| content.text.clone())
.expect("auth-like summarize failure should return text error");
assert!(summarize_error_text.contains("Authentication failed with Kagi"));
assert!(summarize_error_text.contains(ENV_SESSION_TOKEN));
assert!(summarize_error_text.contains(ENV_API_KEY));
assert!(summarize_error_text.contains("may belong"));
assert!(!summarize_error_text.contains("application-level failure"));
client
.cancel()
.await
.expect("client shutdown should succeed");
handle.await.expect("server join should succeed");
}
}
#[test]
fn startup_backend_selection_and_failures_match_contract() {
let default_config = ClientConfig::default();
let auto_prefers_official = BackendRuntime::from_env_config(
EnvConfig {
backend_mode: Some("auto".to_string()),
api_key: Some("official_token".to_string()),
session_token: Some("session_token".to_string()),
},
default_config.clone(),
)
.expect("auto mode should prefer api key");
assert!(matches!(auto_prefers_official, BackendRuntime::Official(_)));
let auto_falls_back_to_session = BackendRuntime::from_env_config(
EnvConfig {
backend_mode: Some("auto".to_string()),
api_key: None,
session_token: Some("session_token".to_string()),
},
default_config.clone(),
)
.expect("auto mode should use session when api key missing");
assert!(matches!(
auto_falls_back_to_session,
BackendRuntime::Session(_)
));
let missing = BackendRuntime::from_env_config(
EnvConfig {
backend_mode: Some("auto".to_string()),
api_key: None,
session_token: None,
},
default_config.clone(),
)
.expect_err("auto mode without any credential must fail");
assert!(missing.to_string().contains(ENV_API_KEY));
let missing_official_with_session_present = BackendRuntime::from_env_config(
EnvConfig {
backend_mode: Some("official".to_string()),
api_key: None,
session_token: Some("session_token".to_string()),
},
default_config.clone(),
)
.expect_err("official mode without api key must fail");
let missing_official_message = missing_official_with_session_present.to_string();
assert!(missing_official_message.contains(ENV_API_KEY));
assert!(missing_official_message.contains(ENV_SESSION_TOKEN));
assert!(missing_official_message.contains("may belong"));
assert!(missing_official_message.contains("session"));
let missing_session_with_api_present = BackendRuntime::from_env_config(
EnvConfig {
backend_mode: Some("session".to_string()),
api_key: Some("official_token".to_string()),
session_token: None,
},
default_config.clone(),
)
.expect_err("session mode without session token must fail");
let missing_session_message = missing_session_with_api_present.to_string();
assert!(missing_session_message.contains(ENV_SESSION_TOKEN));
assert!(missing_session_message.contains(ENV_API_KEY));
assert!(missing_session_message.contains("may belong"));
assert!(missing_session_message.contains("official"));
let invalid_mode = BackendRuntime::from_env_config(
EnvConfig {
backend_mode: Some("invalid".to_string()),
api_key: Some("official_token".to_string()),
session_token: None,
},
default_config.clone(),
)
.expect_err("invalid backend mode must fail");
assert!(invalid_mode.to_string().contains(ENV_BACKEND_MODE));
let invalid_official_credential = BackendRuntime::from_env_config(
EnvConfig {
backend_mode: Some("official".to_string()),
api_key: Some("secret official token".to_string()),
session_token: None,
},
default_config.clone(),
)
.expect_err("invalid official credential must fail");
let invalid_official_message = invalid_official_credential.to_string();
assert!(invalid_official_message.contains(ENV_API_KEY));
assert!(!invalid_official_message.contains("secret official token"));
let invalid_session_credential = BackendRuntime::from_env_config(
EnvConfig {
backend_mode: Some("session".to_string()),
api_key: None,
session_token: Some("secret session token".to_string()),
},
default_config,
)
.expect_err("invalid session credential must fail");
let invalid_session_message = invalid_session_credential.to_string();
assert!(invalid_session_message.contains(ENV_SESSION_TOKEN));
assert!(!invalid_session_message.contains("secret session token"));
}