use super::check_capability;
use crate::report::{TestCategory, TestResult};
use crate::tester::ServerTester;
use pmcp::types::ToolInfo;
use serde_json::json;
use std::time::Instant;
pub async fn run_tools_conformance(tester: &mut ServerTester) -> Vec<TestResult> {
if let Some(skip) = check_capability(tester, "Tools", TestCategory::Tools, |caps| {
caps.tools.is_some()
}) {
return skip;
}
let mut results = Vec::new();
let (list_result, tools) = test_tools_list(tester).await;
results.push(list_result);
results.push(test_tool_schema_validation(&tools));
results.push(test_call_existing_tool(tester, &tools).await);
results.push(test_call_unknown_tool(tester).await);
results.push(test_output_schema_validation(&tools));
results
}
async fn test_tools_list(tester: &mut ServerTester) -> (TestResult, Vec<ToolInfo>) {
let start = Instant::now();
let name = "Tools: list returns valid ToolInfo";
match tester.list_tools().await {
Ok(result) => {
let tools = result.tools;
let invalid_tools: Vec<_> = tools
.iter()
.enumerate()
.filter(|(_, t)| t.name.is_empty())
.map(|(i, _)| i)
.collect();
if invalid_tools.is_empty() {
(
TestResult::passed(
name,
TestCategory::Tools,
start.elapsed(),
format!("Found {} tools", tools.len()),
),
tools,
)
} else {
(
TestResult::failed(
name,
TestCategory::Tools,
start.elapsed(),
format!("Tools at indices {:?} have empty names", invalid_tools),
),
tools,
)
}
},
Err(e) => (
TestResult::failed(
name,
TestCategory::Tools,
start.elapsed(),
format!("tools/list failed: {e}"),
),
Vec::new(),
),
}
}
fn test_tool_schema_validation(tools: &[ToolInfo]) -> TestResult {
let start = Instant::now();
let name = "Tools: input schema validation";
let invalid_schemas: Vec<String> = tools
.iter()
.filter_map(|tool| {
if tool.input_schema.is_null() || tool.input_schema.is_object() {
None
} else {
Some(tool.name.clone())
}
})
.collect();
if invalid_schemas.is_empty() {
TestResult::passed(
name,
TestCategory::Tools,
start.elapsed(),
format!("All {} tool schemas valid", tools.len()),
)
} else {
TestResult::warning(
name,
TestCategory::Tools,
start.elapsed(),
format!("Tools with invalid schemas: {}", invalid_schemas.join(", ")),
)
}
}
async fn test_call_existing_tool(tester: &mut ServerTester, tools: &[ToolInfo]) -> TestResult {
let start = Instant::now();
let name = "Tools: call existing tool";
if tools.is_empty() {
return TestResult::skipped(name, TestCategory::Tools, "No tools available to test");
}
let tool_name = &tools[0].name;
match tester
.send_custom_request("tools/call", json!({"name": tool_name, "arguments": {}}))
.await
{
Ok(response) => {
if response.get("content").is_some() || response.get("isError").is_some() {
TestResult::passed(
name,
TestCategory::Tools,
start.elapsed(),
format!("Tool '{tool_name}' responded correctly"),
)
} else if response.get("error").is_some() {
TestResult::passed(
name,
TestCategory::Tools,
start.elapsed(),
format!("Tool '{tool_name}' returned protocol error (valid)"),
)
} else {
TestResult::warning(
name,
TestCategory::Tools,
start.elapsed(),
format!("Tool '{tool_name}' returned unparseable response"),
)
}
},
Err(_) => TestResult::passed(
name,
TestCategory::Tools,
start.elapsed(),
format!("Tool '{tool_name}' returned error (acceptable for empty args)"),
),
}
}
fn test_output_schema_validation(tools: &[ToolInfo]) -> TestResult {
let start = Instant::now();
let name = "Tools: outputSchema has type object";
let schema_count = tools.iter().filter(|t| t.output_schema.is_some()).count();
if schema_count == 0 {
return TestResult::skipped(name, TestCategory::Tools, "No tools declare outputSchema");
}
let invalid: Vec<String> = tools
.iter()
.filter(|t| t.output_schema.is_some())
.filter_map(|tool| {
let schema = tool.output_schema.as_ref()?;
let is_object = schema.get("type").and_then(|v| v.as_str()) == Some("object");
(!is_object).then(|| tool.name.clone())
})
.collect();
if invalid.is_empty() {
TestResult::passed(
name,
TestCategory::Tools,
start.elapsed(),
format!("All {schema_count} tools with outputSchema have type: object"),
)
} else {
TestResult::warning(
name,
TestCategory::Tools,
start.elapsed(),
format!(
"Tools with outputSchema missing type: object: {}",
invalid.join(", ")
),
)
}
}
async fn test_call_unknown_tool(tester: &mut ServerTester) -> TestResult {
let start = Instant::now();
let name = "Tools: call unknown tool returns error";
match tester
.send_custom_request(
"tools/call",
json!({"name": "___nonexistent_tool_conformance_test___", "arguments": {}}),
)
.await
{
Ok(response) => {
let is_error = response
.get("isError")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if is_error || response.get("error").is_some() {
TestResult::passed(
name,
TestCategory::Tools,
start.elapsed(),
"Server correctly returned error for unknown tool",
)
} else {
TestResult::warning(
name,
TestCategory::Tools,
start.elapsed(),
"Server returned success for nonexistent tool",
)
}
},
Err(_) => TestResult::passed(
name,
TestCategory::Tools,
start.elapsed(),
"Server correctly rejected unknown tool",
),
}
}