//! Agent-UX validation rules
//!
//! Based on Phil Schmid's "MCP is a User Interface for Agents" principles:
//!
//! 1. **Outcomes, Not Operations** - Tools should achieve goals, not wrap API operations
//! 2. **Flatten Your Arguments** - Use primitives, not nested objects
//! 3. **Instructions are Context** - Documentation is agent context
//! 4. **Curate Ruthlessly** - Return only what's needed
//! 5. **Name for Discovery** - Service-prefixed, searchable names
//! 6. **Paginate Large Results** - Include pagination and metadata
use crate::protocol::Tool;
use crate::Result;
use super::{ValidationResult, Severity};
use regex::Regex;
use std::sync::OnceLock;
/// Validation rule trait
pub trait Rule: Send + Sync {
/// Get rule code (e.g., "AX-001")
fn code(&self) -> &str;
/// Get rule name/principle
fn name(&self) -> &str;
/// Get rule description
fn description(&self) -> &str;
/// Check a tool against this rule
fn check(&self, tool: &Tool) -> Result<ValidationResult>;
}
/// Collection of all validation rules
pub struct ValidationRules {
rules: Vec<Box<dyn Rule>>,
}
impl Default for ValidationRules {
fn default() -> Self {
Self::new()
}
}
impl ValidationRules {
/// Create default rule set
#[must_use]
pub fn new() -> Self {
let mut rules: Vec<Box<dyn Rule>> = Vec::new();
rules.push(Box::new(OutcomeOrientedRule));
rules.push(Box::new(FlatArgumentsRule));
rules.push(Box::new(DocumentationQualityRule));
rules.push(Box::new(ResponseCurationRule));
rules.push(Box::new(NamingDiscoveryRule));
rules.push(Box::new(PaginationRule));
Self { rules }
}
/// Get all rules
#[must_use]
pub fn all_rules(&self) -> &[Box<dyn Rule>] {
&self.rules
}
}
/// AX-001: Outcomes, Not Operations
///
/// Tools should achieve agent goals, not wrap API operations.
/// Red flags: CRUD operations, API-wrapper naming
struct OutcomeOrientedRule;
impl Rule for OutcomeOrientedRule {
fn code(&self) -> &str {
"AX-001"
}
fn name(&self) -> &str {
"Outcomes, Not Operations"
}
fn description(&self) -> &str {
"Tools should achieve goals, not wrap API operations"
}
fn check(&self, tool: &Tool) -> Result<ValidationResult> {
let mut result = ValidationResult::new(self.code(), self.name(), &tool.name);
let name_lower = tool.name.to_lowercase();
let desc = tool.description.as_deref().unwrap_or("");
let desc_lower = desc.to_lowercase();
// CRUD patterns that suggest operation-oriented design
let crud_patterns = [
"create_", "read_", "update_", "delete_",
"get_", "set_", "list_", "fetch_",
"retrieve_", "insert_", "remove_", "add_",
];
// Check name for CRUD patterns
for pattern in &crud_patterns {
if name_lower.starts_with(pattern) {
result.add_issue(format!("Name '{}' starts with '{}' suggesting operation, not outcome", tool.name, pattern));
result.add_suggestion("Rename to describe what agent achieves (e.g., 'find_', 'search_', 'analyze_')");
break;
}
}
// Check for API-wrapper language in description
let api_wrapper_terms = [
"calls the api", "api endpoint", "rest api",
"wrapper", "proxy to", "forwards to",
];
for term in &api_wrapper_terms {
if desc_lower.contains(term) {
result.add_issue(format!("Description mentions '{term}' - focus on agent outcomes, not implementation"));
result.add_suggestion("Describe what the agent accomplishes, not how the API is called");
break;
}
}
// Positive patterns: outcome verbs
let outcome_verbs = [
"find", "search", "analyze", "summarize",
"extract", "generate", "transform", "validate",
"calculate", "compare", "discover", "identify",
];
let has_outcome_verb = outcome_verbs.iter().any(|v|
name_lower.contains(v) || desc_lower.contains(v)
);
if !has_outcome_verb && result.issues.is_empty() {
result.add_issue("Tool lacks outcome-oriented verbs in name or description");
result.add_suggestion("Use action verbs that describe agent goals: find, search, analyze, etc.");
}
// Calculate score
let score = if result.issues.is_empty() {
1.0
} else if has_outcome_verb {
0.7 // Has some outcome language but also issues
} else {
0.3 // Pure CRUD/API wrapper
};
let severity = if score < 0.5 {
Severity::Fail
} else if score < 0.8 {
Severity::Warn
} else {
Severity::Pass
};
// Update passed status based on final severity and score
result.passed = result.issues.is_empty() && severity == Severity::Pass;
Ok(result.with_score(score).with_severity(severity))
}
}
/// AX-002: Flatten Your Arguments
///
/// Arguments should be primitives or enums, not nested objects
struct FlatArgumentsRule;
impl Rule for FlatArgumentsRule {
fn code(&self) -> &str {
"AX-002"
}
fn name(&self) -> &str {
"Flatten Your Arguments"
}
fn description(&self) -> &str {
"Arguments should be primitives/enums, not nested objects"
}
fn check(&self, tool: &Tool) -> Result<ValidationResult> {
let mut result = ValidationResult::new(self.code(), self.name(), &tool.name);
let properties = tool.input_schema
.get("properties")
.and_then(|p| p.as_object());
if let Some(props) = properties {
let mut nesting_count = 0;
for (name, prop) in props {
let prop_type = prop.get("type").and_then(|t| t.as_str()).unwrap_or("");
// Check for nested objects
if prop_type == "object" {
result.add_issue(format!("Parameter '{name}' is a nested object - flatten to primitives"));
nesting_count += 1;
}
// Check for arrays of objects
if prop_type == "array" {
if let Some(items) = prop.get("items") {
let items_type = items.get("type").and_then(|t| t.as_str()).unwrap_or("");
if items_type == "object" {
result.add_issue(format!("Parameter '{name}' is an array of objects - simplify structure"));
nesting_count += 1;
}
}
}
}
if nesting_count > 0 {
result.add_suggestion("Use primitives: string, number, boolean, enum");
result.add_suggestion("For complex data, use multiple flat parameters or string encoding (JSON, CSV)");
}
// Score based on nesting depth
let score = if nesting_count == 0 {
1.0
} else {
(1.0 - (nesting_count as f64 * 0.3)).max(0.0)
};
let severity = if score < 0.5 {
Severity::Fail
} else if score < 0.8 {
Severity::Warn
} else {
Severity::Pass
};
result.passed = result.issues.is_empty() && severity == Severity::Pass;
Ok(result.with_score(score).with_severity(severity))
} else {
// No properties defined - give neutral score
result.passed = true;
Ok(result.with_score(0.5).with_severity(Severity::Info))
}
}
}
/// AX-003: Instructions are Context
///
/// Docstrings and error messages are agent context, not just human documentation
struct DocumentationQualityRule;
impl Rule for DocumentationQualityRule {
fn code(&self) -> &str {
"AX-003"
}
fn name(&self) -> &str {
"Instructions are Context"
}
fn description(&self) -> &str {
"Documentation should provide rich context for agents"
}
fn check(&self, tool: &Tool) -> Result<ValidationResult> {
let mut result = ValidationResult::new(self.code(), self.name(), &tool.name);
let desc = tool.description.as_deref().unwrap_or("");
let mut quality_score = 1.0;
// Check description length (too short lacks context)
if desc.len() < 50 {
result.add_issue("Description too short - agents need rich context");
result.add_suggestion("Add 2-3 sentences explaining when to use this tool and what it returns");
quality_score -= 0.3;
}
// Check for contextual keywords
let context_keywords = ["use", "when", "returns", "helps", "provides", "enables"];
let has_context = context_keywords.iter().any(|k| desc.to_lowercase().contains(k));
if !has_context {
result.add_issue("Description lacks usage guidance");
result.add_suggestion("Explain WHEN to use this tool and WHAT it provides");
quality_score -= 0.2;
}
// Check parameter descriptions
let properties = tool.input_schema
.get("properties")
.and_then(|p| p.as_object());
if let Some(props) = properties {
let mut missing_desc = 0;
for (name, prop) in props {
if !prop.get("description").is_some_and(|d| !d.as_str().unwrap_or("").is_empty()) {
result.add_issue(format!("Parameter '{name}' missing description"));
missing_desc += 1;
}
}
if missing_desc > 0 {
result.add_suggestion("Add descriptions to all parameters with examples");
quality_score -= missing_desc as f64 * 0.15;
}
}
// Check for examples
let has_example = desc.contains("example") || desc.contains("e.g.") || desc.contains("for instance");
if !has_example && desc.len() > 50 {
result.add_issue("No examples provided");
result.add_suggestion("Include concrete examples of usage");
quality_score -= 0.1;
}
quality_score = quality_score.max(0.0);
let severity = if quality_score < 0.5 {
Severity::Fail
} else if quality_score < 0.7 {
Severity::Warn
} else if quality_score < 0.9 {
Severity::Info
} else {
Severity::Pass
};
result.passed = result.issues.is_empty() || severity == Severity::Pass || severity == Severity::Info;
Ok(result.with_score(quality_score).with_severity(severity))
}
}
/// AX-004: Curate Ruthlessly
///
/// Return only what the agent needs, not full API responses
struct ResponseCurationRule;
impl Rule for ResponseCurationRule {
fn code(&self) -> &str {
"AX-004"
}
fn name(&self) -> &str {
"Curate Ruthlessly"
}
fn description(&self) -> &str {
"Return only what agent needs, not full API responses"
}
fn check(&self, tool: &Tool) -> Result<ValidationResult> {
let mut result = ValidationResult::new(self.code(), self.name(), &tool.name);
let desc = tool.description.as_deref().unwrap_or("").to_lowercase();
let mut curation_score: f64 = 1.0;
// Red flags: returning everything
let over_return_patterns = [
("all data", "Returning 'all data' - consider filtering/summarizing"),
("full response", "Returning 'full response' - curate to essential fields"),
("entire", "Returning 'entire' response - extract key information"),
("complete", "Returning 'complete' data - select relevant subset"),
];
for (pattern, issue) in &over_return_patterns {
if desc.contains(pattern) {
result.add_issue(issue.to_string());
curation_score -= 0.3;
break;
}
}
// Check output schema if present
if let Some(output_schema) = &tool.output_schema {
let properties = output_schema
.get("properties")
.and_then(|p| p.as_object());
if let Some(props) = properties {
let field_count = props.len();
// Too many fields suggests lack of curation
if field_count > 15 {
result.add_issue(format!("Output has {field_count} fields - consider curating to essential data"));
result.add_suggestion("Reduce to 5-10 most relevant fields for agent decision-making");
curation_score -= 0.2;
} else if field_count > 10 {
result.add_issue(format!("Output has {field_count} fields - verify all are necessary"));
result.add_suggestion("Review if all fields are needed for agent tasks");
curation_score -= 0.1;
}
}
}
// Positive signals
let curation_keywords = ["summarize", "extract", "key", "relevant", "essential", "filtered"];
let has_curation = curation_keywords.iter().any(|k| desc.contains(k));
if has_curation {
result.add_suggestion("Good: Tool indicates data curation");
} else if curation_score < 1.0 {
result.add_suggestion("Focus on extracting key information, not dumping full API responses");
}
curation_score = curation_score.max(0.0);
let severity = if curation_score < 0.5 {
Severity::Warn
} else if curation_score < 0.8 {
Severity::Info
} else {
Severity::Pass
};
result.passed = result.issues.is_empty() || severity == Severity::Pass || severity == Severity::Info;
Ok(result.with_score(curation_score).with_severity(severity))
}
}
/// AX-005: Name for Discovery
///
/// Service-prefixed names for easy discovery in large tool lists
struct NamingDiscoveryRule;
impl Rule for NamingDiscoveryRule {
fn code(&self) -> &str {
"AX-005"
}
fn name(&self) -> &str {
"Name for Discovery"
}
fn description(&self) -> &str {
"Service-prefixed names for easy discovery"
}
fn check(&self, tool: &Tool) -> Result<ValidationResult> {
let mut result = ValidationResult::new(self.code(), self.name(), &tool.name);
let mut discovery_score: f64 = 1.0;
// Check for service prefix pattern (service_action)
static SEPARATOR_RE: OnceLock<Regex> = OnceLock::new();
let separator_re = SEPARATOR_RE.get_or_init(|| Regex::new(r"[_-]").unwrap());
let parts: Vec<&str> = separator_re.split(&tool.name).collect();
if parts.len() < 2 {
result.add_issue("No service prefix - hard to discover in large tool lists");
result.add_suggestion("Use pattern: service_action (e.g., github_search_issues, slack_send_message)");
discovery_score -= 0.4;
}
// Check name length
if tool.name.len() < 5 {
result.add_issue("Name too short for good discoverability");
result.add_suggestion("Use descriptive names that clearly indicate purpose");
discovery_score -= 0.2;
}
// Check for ambiguous names
let ambiguous_terms = ["tool", "handler", "helper", "util", "misc"];
for term in &ambiguous_terms {
if tool.name.to_lowercase().contains(term) {
result.add_issue(format!("Name contains ambiguous term '{term}'"));
result.add_suggestion("Use specific, descriptive names that indicate the service and action");
discovery_score -= 0.2;
break;
}
}
// Check for consistent naming convention
let has_snake_case = tool.name.contains('_');
let has_kebab_case = tool.name.contains('-');
let has_camel_case = tool.name.chars().any(|c| c.is_uppercase());
let conventions_used = [has_snake_case, has_kebab_case, has_camel_case]
.iter()
.filter(|&&x| x)
.count();
if conventions_used > 1 {
result.add_issue("Mixed naming conventions (snake_case, kebab-case, camelCase)");
result.add_suggestion("Use consistent snake_case for tool names");
discovery_score -= 0.1;
}
discovery_score = discovery_score.max(0.0);
let severity = if discovery_score < 0.5 {
Severity::Warn
} else if discovery_score < 0.8 {
Severity::Info
} else {
Severity::Pass
};
result.passed = result.issues.is_empty() || severity == Severity::Pass || severity == Severity::Info;
Ok(result.with_score(discovery_score).with_severity(severity))
}
}
/// AX-006: Paginate Large Results
///
/// Include pagination params and metadata for list operations
struct PaginationRule;
impl Rule for PaginationRule {
fn code(&self) -> &str {
"AX-006"
}
fn name(&self) -> &str {
"Paginate Large Results"
}
fn description(&self) -> &str {
"Include pagination for list operations with metadata"
}
fn check(&self, tool: &Tool) -> Result<ValidationResult> {
let mut result = ValidationResult::new(self.code(), self.name(), &tool.name);
let desc = tool.description.as_deref().unwrap_or("").to_lowercase();
let name_lower = tool.name.to_lowercase();
// Determine if this is a list/search operation
let list_indicators = ["list", "search", "find", "query", "all", "multiple"];
let is_list_operation = list_indicators.iter().any(|i|
name_lower.contains(i) || desc.contains(i)
);
if !is_list_operation {
// Not a list operation, pagination not required
result.passed = true;
return Ok(result.with_score(1.0).with_severity(Severity::Pass));
}
// Check for pagination parameters
let properties = tool.input_schema
.get("properties")
.and_then(|p| p.as_object());
let pagination_params = ["limit", "offset", "page", "cursor", "page_size", "max_results"];
let has_pagination = properties.is_some_and(|props| {
pagination_params.iter().any(|param| props.contains_key(*param))
});
let mut pagination_score: f64 = 1.0;
if !has_pagination {
result.add_issue("List operation lacks pagination parameters");
result.add_suggestion("Add 'limit' and 'offset' (or 'page' and 'page_size') parameters");
pagination_score -= 0.5;
}
// Check output schema for pagination metadata
if let Some(output_schema) = &tool.output_schema {
let properties = output_schema
.get("properties")
.and_then(|p| p.as_object());
let metadata_fields = ["total", "total_count", "has_more", "next_cursor", "page_info"];
let has_metadata = properties.is_some_and(|props| {
metadata_fields.iter().any(|field| props.contains_key(*field))
});
if !has_metadata {
result.add_issue("Output lacks pagination metadata (total_count, has_more, etc.)");
result.add_suggestion("Include metadata fields: total_count, has_more, next_cursor");
pagination_score -= 0.3;
}
} else {
// No output schema defined for list operation
result.add_issue("List operation should define output schema with pagination metadata");
result.add_suggestion("Define output schema including items array and pagination metadata");
pagination_score -= 0.2;
}
pagination_score = pagination_score.max(0.0);
let severity = if pagination_score < 0.5 {
Severity::Fail
} else if pagination_score < 0.8 {
Severity::Warn
} else {
Severity::Pass
};
result.passed = result.issues.is_empty() || severity == Severity::Pass;
Ok(result.with_score(pagination_score).with_severity(severity))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn create_tool(name: &str, description: &str, input_schema: serde_json::Value) -> Tool {
Tool {
name: name.to_string(),
title: None,
description: Some(description.to_string()),
input_schema,
output_schema: None,
annotations: None,
}
}
#[test]
fn test_outcome_oriented_rule_pass() {
let rule = OutcomeOrientedRule;
let tool = create_tool(
"github_search_issues",
"Find and analyze GitHub issues matching search criteria",
json!({"type": "object", "properties": {}})
);
let result = rule.check(&tool).unwrap();
assert!(result.score > 0.8);
}
#[test]
fn test_outcome_oriented_rule_fail() {
let rule = OutcomeOrientedRule;
let tool = create_tool(
"get_user",
"Calls the API endpoint to retrieve user data",
json!({"type": "object", "properties": {}})
);
let result = rule.check(&tool).unwrap();
assert!(result.score < 0.5);
assert!(!result.issues.is_empty());
}
#[test]
fn test_flat_arguments_rule_pass() {
let rule = FlatArgumentsRule;
let tool = create_tool(
"search",
"Search",
json!({
"type": "object",
"properties": {
"query": {"type": "string"},
"limit": {"type": "number"}
}
})
);
let result = rule.check(&tool).unwrap();
assert!(result.score > 0.9);
}
#[test]
fn test_flat_arguments_rule_fail() {
let rule = FlatArgumentsRule;
let tool = create_tool(
"search",
"Search",
json!({
"type": "object",
"properties": {
"filter": {
"type": "object",
"properties": {"field": {"type": "string"}}
}
}
})
);
let result = rule.check(&tool).unwrap();
assert!(result.score < 0.8);
assert!(!result.issues.is_empty());
}
#[test]
fn test_documentation_quality_good() {
let rule = DocumentationQualityRule;
let tool = create_tool(
"search",
"Search the knowledge base for relevant documents. Use this when you need to find information about a specific topic. Returns a list of matching documents with relevance scores.",
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query string"
}
}
})
);
let result = rule.check(&tool).unwrap();
assert!(result.score > 0.8);
}
#[test]
fn test_naming_discovery_good() {
let rule = NamingDiscoveryRule;
let tool = create_tool(
"github_search_issues",
"Search GitHub issues",
json!({"type": "object"})
);
let result = rule.check(&tool).unwrap();
assert!(result.score > 0.8);
}
#[test]
fn test_naming_discovery_bad() {
let rule = NamingDiscoveryRule;
let tool = create_tool(
"search",
"Search",
json!({"type": "object"})
);
let result = rule.check(&tool).unwrap();
assert!(result.score < 0.7);
}
#[test]
fn test_pagination_rule_list_operation() {
let rule = PaginationRule;
let tool = create_tool(
"list_users",
"List all users",
json!({
"type": "object",
"properties": {
"limit": {"type": "number"},
"offset": {"type": "number"}
}
})
);
let result = rule.check(&tool).unwrap();
// Has pagination params but no output schema
assert!(result.score > 0.4);
}
}