#[cfg(target_os = "linux")]
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
use agnix_core::{
config::LintConfig,
diagnostics::{Diagnostic, DiagnosticLevel},
validate_file as core_validate_file, validate_project as core_validate_project,
};
use rmcp::{
ServerHandler, ServiceExt,
handler::server::{tool::ToolRouter, wrapper::Parameters},
model::{
CallToolResult, ContentBlock, ErrorData as McpError, Implementation, ProtocolVersion,
ServerCapabilities, ServerInfo,
},
schemars, tool, tool_handler, tool_router,
transport::stdio,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::any::Any;
use std::collections::HashSet;
use std::fmt::Display;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::path::{Path, PathBuf};
const TOOL_ALIASES: &[(&str, &str)] =
&[("copilot", "github-copilot"), ("claudecode", "claude-code")];
const COMPAT_TOOL_NAMES: &[&str] = &["generic", "codex"];
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[schemars(description = "Input for validating a single agent configuration file")]
pub struct ValidateFileInput {
#[schemars(
description = "Path to an agent configuration file inside the server working directory (e.g., 'SKILL.md', '.claude/settings.json', 'mcp-config.json')"
)]
pub path: String,
#[schemars(
description = "Tools to validate for. Preferred: JSON array of tool names (e.g. [\"claude-code\", \"cursor\"]). Also accepts comma-separated string (e.g. \"claude-code,cursor\") as a fallback. Uses canonical agnix tool names (case-insensitive), plus compatibility aliases (e.g. \"copilot\", \"claudecode\"). When non-empty, this overrides legacy target."
)]
pub tools: Option<ToolsInput>,
#[schemars(
description = "Legacy single target for validation rules (deprecated). Options: 'generic' (default), 'claude-code', 'cursor', 'codex', 'kiro'. Used only when 'tools' is missing or empty."
)]
pub target: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[schemars(description = "Input for validating all agent configs in a project directory")]
pub struct ValidateProjectInput {
#[schemars(
description = "Path to the project directory to validate (e.g., '.' for current directory)"
)]
pub path: String,
#[schemars(
description = "Tools to validate for. Preferred: JSON array of tool names (e.g. [\"claude-code\", \"cursor\"]). Also accepts comma-separated string (e.g. \"claude-code,cursor\") as a fallback. Uses canonical agnix tool names (case-insensitive), plus compatibility aliases (e.g. \"copilot\", \"claudecode\"). When non-empty, this overrides legacy target."
)]
pub tools: Option<ToolsInput>,
#[schemars(
description = "Legacy single target for validation rules (deprecated). Options: 'generic' (default), 'claude-code', 'cursor', 'codex', 'kiro'. Used only when 'tools' is missing or empty."
)]
pub target: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum ToolsInput {
List(Vec<String>),
Csv(String),
}
impl schemars::JsonSchema for ToolsInput {
fn schema_name() -> std::borrow::Cow<'static, str> {
"ToolsInput".into()
}
fn schema_id() -> std::borrow::Cow<'static, str> {
concat!(module_path!(), "::ToolsInput").into()
}
fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"anyOf": [
{
"type": "array",
"items": { "type": "string" },
"description": "Preferred: array of tool names, e.g. [\"claude-code\", \"cursor\"]"
},
{
"type": "string",
"description": "Fallback: comma-separated tool names, e.g. \"claude-code,cursor\""
}
]
})
}
fn inline_schema() -> bool {
true
}
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[schemars(description = "Input for looking up a specific validation rule")]
pub struct GetRuleDocsInput {
#[schemars(
description = "Rule ID to look up documentation for. Format: PREFIX-NUMBER (e.g., 'AS-004', 'CC-SK-001', 'PE-003', 'MCP-001')"
)]
pub rule_id: String,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
struct DiagnosticOutput {
file: String,
line: usize,
column: usize,
level: String,
rule: String,
message: String,
suggestion: Option<String>,
fixable: bool,
#[serde(skip_serializing_if = "Option::is_none")]
category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
rule_severity: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
applies_to_tool: Option<String>,
}
impl From<&Diagnostic> for DiagnosticOutput {
fn from(d: &Diagnostic) -> Self {
Self::from_diagnostic(d, None)
}
}
impl DiagnosticOutput {
fn from_diagnostic(d: &Diagnostic, workspace_root: Option<&Path>) -> Self {
Self {
file: workspace_root
.map(|root| display_path_for_client(&d.file, root))
.unwrap_or_else(|| d.file.display().to_string()),
line: d.line,
column: d.column,
level: match d.level {
DiagnosticLevel::Error => "error",
DiagnosticLevel::Warning => "warning",
DiagnosticLevel::Info => "info",
}
.to_string(),
rule: d.rule.clone(),
message: d.message.clone(),
suggestion: d.suggestion.clone(),
fixable: !d.fixes.is_empty(),
category: d.metadata.as_ref().map(|m| m.category.clone()),
rule_severity: d.metadata.as_ref().map(|m| m.severity.clone()),
applies_to_tool: d.metadata.as_ref().and_then(|m| m.applies_to_tool.clone()),
}
}
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
struct ValidationResult {
path: String,
files_checked: usize,
errors: usize,
warnings: usize,
fixable: usize,
diagnostics: Vec<DiagnosticOutput>,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
struct RuleInfo {
id: String,
name: String,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
struct RulesListOutput {
count: usize,
rules: Vec<RuleInfo>,
}
fn parse_target(target: Option<String>) -> agnix_core::config::TargetTool {
use agnix_core::config::TargetTool;
match target.as_deref() {
Some("claude-code") | Some("claudecode") => TargetTool::ClaudeCode,
Some("cursor") => TargetTool::Cursor,
Some("codex") => TargetTool::Codex,
Some("kiro") => TargetTool::Kiro,
_ => TargetTool::Generic,
}
}
fn normalize_tool_entry(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_ascii_lowercase())
}
}
fn canonicalize_tool(value: &str) -> Option<&'static str> {
match value {
v if v.eq_ignore_ascii_case("generic") => Some("generic"),
v if v.eq_ignore_ascii_case("codex") => Some("codex"),
_ => TOOL_ALIASES
.iter()
.find(|(alias, _)| value.eq_ignore_ascii_case(alias))
.map(|(_, canonical)| *canonical)
.or_else(|| agnix_rules::normalize_tool_name(value)),
}
}
fn supported_tool_names() -> Vec<&'static str> {
let mut tools = agnix_rules::valid_tools().to_vec();
for compat in COMPAT_TOOL_NAMES {
if !tools.contains(compat) {
tools.push(compat);
}
}
tools.sort_unstable();
tools
}
fn alias_help() -> String {
TOOL_ALIASES
.iter()
.map(|(alias, canonical)| format!("{} -> {}", alias, canonical))
.collect::<Vec<_>>()
.join(", ")
}
fn parse_tools(tools: Option<ToolsInput>) -> Result<Vec<String>, McpError> {
let raw: Vec<String> = match tools {
None => Vec::new(),
Some(ToolsInput::Csv(csv)) => csv.split(',').filter_map(normalize_tool_entry).collect(),
Some(ToolsInput::List(list)) => list
.into_iter()
.filter_map(|entry| normalize_tool_entry(&entry))
.collect(),
};
if raw.is_empty() {
return Ok(Vec::new());
}
let mut seen = HashSet::new();
let mut normalized = Vec::new();
for tool in raw {
let canonical = canonicalize_tool(&tool).ok_or_else(|| {
make_invalid_params(format!(
"Unknown tool '{}'. Valid values: {}. Aliases: {}.",
tool,
supported_tool_names().join(", "),
alias_help()
))
})?;
if seen.insert(canonical) {
normalized.push(canonical.to_string());
}
}
Ok(normalized)
}
fn apply_tool_selection(
config: &mut LintConfig,
tools: Option<ToolsInput>,
target: Option<String>,
) -> Result<(), McpError> {
let parsed_tools = parse_tools(tools)?;
if parsed_tools.is_empty() {
config.tools_mut().clear();
config.set_target(parse_target(target));
} else {
config.set_target(agnix_core::config::TargetTool::Generic);
config.set_tools(parsed_tools);
}
Ok(())
}
fn diagnostics_to_result(
path: &str,
diagnostics: Vec<Diagnostic>,
files_checked: usize,
workspace_root: &Path,
) -> ValidationResult {
let errors = diagnostics
.iter()
.filter(|d| matches!(d.level, DiagnosticLevel::Error))
.count();
let warnings = diagnostics
.iter()
.filter(|d| matches!(d.level, DiagnosticLevel::Warning))
.count();
let fixable = diagnostics.iter().filter(|d| !d.fixes.is_empty()).count();
ValidationResult {
path: path.to_string(),
files_checked,
errors,
warnings,
fixable,
diagnostics: diagnostics
.iter()
.map(|d| DiagnosticOutput::from_diagnostic(d, Some(workspace_root)))
.collect(),
}
}
fn make_internal_error(msg: String) -> McpError {
McpError::internal_error(msg, None::<Value>)
}
fn make_invalid_params(msg: String) -> McpError {
McpError::invalid_params(msg, None::<Value>)
}
fn resolve_path_within_workspace(
input_path: &str,
workspace_root: &Path,
) -> Result<PathBuf, String> {
let canonical_root = workspace_root
.canonicalize()
.map_err(|e| format!("Failed to resolve workspace root: {e}"))?;
let requested = Path::new(input_path);
let candidate = if requested.is_absolute() {
requested.to_path_buf()
} else {
canonical_root.join(requested)
};
let canonical_candidate = candidate
.canonicalize()
.map_err(|e| format!("Failed to resolve path: {e}"))?;
if !canonical_candidate.starts_with(&canonical_root) {
return Err(format!("Path outside workspace boundary: {input_path}"));
}
Ok(canonical_candidate)
}
struct ResolvedMcpPath {
canonical_path: PathBuf,
workspace_root: PathBuf,
}
fn resolve_mcp_path_with_root(input_path: &str) -> Result<ResolvedMcpPath, McpError> {
let workspace_root = std::env::current_dir()
.map_err(|e| make_internal_error(format!("Failed to read current directory: {e}")))?
.canonicalize()
.map_err(|e| make_internal_error(format!("Failed to resolve current directory: {e}")))?;
let canonical_path =
resolve_path_within_workspace(input_path, &workspace_root).map_err(make_invalid_params)?;
Ok(ResolvedMcpPath {
canonical_path,
workspace_root,
})
}
fn display_path_for_client(path: &Path, workspace_root: &Path) -> String {
let display_path = path.strip_prefix(workspace_root).unwrap_or(path);
if display_path.as_os_str().is_empty() {
".".to_string()
} else {
display_path.display().to_string()
}
}
fn sanitize_error_message(error: impl Display, workspace_root: &Path) -> String {
let mut message = error.to_string();
let root_display = workspace_root.display().to_string();
if !root_display.is_empty() {
message = message.replace(&root_display, ".");
}
message
}
fn panic_payload_message(payload: &(dyn Any + Send)) -> String {
if let Some(msg) = payload.downcast_ref::<&str>() {
(*msg).to_string()
} else if let Some(msg) = payload.downcast_ref::<String>() {
msg.clone()
} else {
"unknown panic payload".to_string()
}
}
fn run_validation_guarded<T, E, F>(
operation: &str,
input_path: &str,
workspace_root: &Path,
validate: F,
) -> Result<T, McpError>
where
E: Display,
F: FnOnce() -> Result<T, E>,
{
match catch_unwind(AssertUnwindSafe(validate)) {
Ok(Ok(value)) => Ok(value),
Ok(Err(error)) => Err(make_invalid_params(format!(
"Failed to {operation} '{}': {}",
input_path,
sanitize_error_message(error, workspace_root)
))),
Err(payload) => Err(make_internal_error(format!(
"Validation panicked while processing '{}': {}",
input_path,
panic_payload_message(payload.as_ref())
))),
}
}
#[derive(Debug, Clone)]
pub struct AgnixServer {
#[allow(dead_code)] tool_router: ToolRouter<AgnixServer>,
}
impl Default for AgnixServer {
fn default() -> Self {
Self::new()
}
}
#[tool_router]
impl AgnixServer {
pub fn new() -> Self {
Self {
tool_router: Self::tool_router(),
}
}
#[tool(
description = "Validate a single agent configuration file against agnix rules. Supports SKILL.md, CLAUDE.md, AGENTS.md, hooks.json, *.mcp.json, .cursor/rules/*.mdc, and other agent config files. Returns diagnostics with errors, warnings, auto-fix suggestions, and rule IDs for lookup."
)]
async fn validate_file(
&self,
Parameters(input): Parameters<ValidateFileInput>,
) -> Result<CallToolResult, McpError> {
let mut config = LintConfig::default();
apply_tool_selection(&mut config, input.tools, input.target)?;
let resolved_path = resolve_mcp_path_with_root(&input.path)?;
let outcome = run_validation_guarded(
"validate file",
&input.path,
&resolved_path.workspace_root,
|| core_validate_file(&resolved_path.canonical_path, &config),
)?;
let diagnostics = outcome.into_diagnostics();
let result =
diagnostics_to_result(&input.path, diagnostics, 1, &resolved_path.workspace_root);
let json = serde_json::to_string_pretty(&result)
.map_err(|e| make_internal_error(format!("Failed to serialize result: {}", e)))?;
Ok(CallToolResult::success(vec![ContentBlock::text(json)]))
}
#[tool(
description = "Validate all agent configuration files in a project directory. Recursively finds and validates SKILL.md, CLAUDE.md, AGENTS.md, hooks, MCP configs, Cursor rules, and more. Returns aggregated diagnostics for all files."
)]
async fn validate_project(
&self,
Parameters(input): Parameters<ValidateProjectInput>,
) -> Result<CallToolResult, McpError> {
let mut config = LintConfig::default();
apply_tool_selection(&mut config, input.tools, input.target)?;
let resolved_path = resolve_mcp_path_with_root(&input.path)?;
let validation_result = run_validation_guarded(
"validate project",
&input.path,
&resolved_path.workspace_root,
|| core_validate_project(&resolved_path.canonical_path, &config),
)?;
let result = diagnostics_to_result(
&input.path,
validation_result.diagnostics,
validation_result.files_checked,
&resolved_path.workspace_root,
);
let json = serde_json::to_string_pretty(&result)
.map_err(|e| make_internal_error(format!("Failed to serialize result: {}", e)))?;
Ok(CallToolResult::success(vec![ContentBlock::text(json)]))
}
#[tool(
description = "List all validation rules available in agnix. Returns rule IDs and names organized by category (AS-* Agent Skills, CC-* Claude Code, MCP-* Model Context Protocol, COP-* Copilot, CUR-* Cursor, etc.)."
)]
async fn get_rules(&self) -> Result<CallToolResult, McpError> {
let rules: Vec<RuleInfo> = agnix_rules::RULES_DATA
.iter()
.map(|(id, name)| RuleInfo {
id: (*id).to_string(),
name: (*name).to_string(),
})
.collect();
let output = RulesListOutput {
count: rules.len(),
rules,
};
let json = serde_json::to_string_pretty(&output)
.map_err(|e| make_internal_error(format!("Failed to serialize rules: {}", e)))?;
Ok(CallToolResult::success(vec![ContentBlock::text(json)]))
}
#[tool(
description = "Get the name of a specific validation rule by ID. Rule IDs follow patterns like AS-004 (Agent Skills), CC-SK-001 (Claude Code Skills), PE-003 (Prompt Engineering), MCP-001 (Model Context Protocol)."
)]
async fn get_rule_docs(
&self,
Parameters(input): Parameters<GetRuleDocsInput>,
) -> Result<CallToolResult, McpError> {
let name = agnix_rules::get_rule_name(&input.rule_id).ok_or_else(|| {
make_invalid_params(format!(
"Rule not found: {}. Use get_rules to list all available rules.",
input.rule_id
))
})?;
let output = RuleInfo {
id: input.rule_id,
name: name.to_string(),
};
let json = serde_json::to_string_pretty(&output)
.map_err(|e| make_internal_error(format!("Failed to serialize rule: {}", e)))?;
Ok(CallToolResult::success(vec![ContentBlock::text(json)]))
}
}
#[tool_handler]
impl ServerHandler for AgnixServer {
fn get_info(&self) -> ServerInfo {
let mut server_impl = Implementation::default();
server_impl.name = "agnix".into();
server_impl.version = env!("CARGO_PKG_VERSION").into();
let mut info = ServerInfo::default();
info.protocol_version = ProtocolVersion::V_2024_11_05;
info.capabilities = ServerCapabilities::builder().enable_tools().build();
info.server_info = server_impl;
let rule_count = agnix_rules::rule_count();
info.instructions = Some(format!(
"Agnix - AI agent configuration linter.\n\n\
Validates SKILL.md, CLAUDE.md, AGENTS.md, hooks, MCP configs, \
Cursor rules, and more against {rule_count} rules.\n\n\
Tools:\n\
- validate_project: Validate all agent configs in a directory\n\
- validate_file: Validate a single config file\n\
- get_rules: List all {rule_count} validation rules\n\
- get_rule_docs: Get details about a specific rule\n\n\
Preferred input: tools (array of tool names, or comma-separated string as fallback)\n\
Legacy fallback: target\n\
Supported tools are derived from agnix rule metadata"
));
info
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive(tracing::Level::WARN.into()),
)
.with_writer(std::io::stderr)
.init();
let server = AgnixServer::new();
let service = server.serve(stdio()).await?;
service.waiting().await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
ToolsInput, ValidateFileInput, ValidateProjectInput, apply_tool_selection,
diagnostics_to_result, display_path_for_client, make_internal_error, make_invalid_params,
parse_tools, resolve_path_within_workspace, run_validation_guarded, sanitize_error_message,
};
use agnix_core::LintConfig;
use agnix_core::config::TargetTool;
use agnix_core::diagnostics::{Diagnostic, LintError, ValidationError};
use rmcp::model::ErrorCode;
use serde_json::json;
use std::path::PathBuf;
#[test]
fn test_parse_tools_csv_trims_and_discards_empty_entries() {
let tools = parse_tools(Some(ToolsInput::Csv(
"claude-code, cursor, ,codex,, ".to_string(),
)))
.expect("valid tools should parse");
assert_eq!(tools, vec!["claude-code", "cursor", "codex"]);
}
#[test]
fn test_parse_tools_array_trims_and_discards_empty_entries() {
let tools = parse_tools(Some(ToolsInput::List(vec![
" claude-code ".to_string(),
"".to_string(),
" cursor".to_string(),
" ".to_string(),
])))
.expect("valid tools should parse");
assert_eq!(tools, vec!["claude-code", "cursor"]);
}
#[test]
fn test_parse_tools_canonicalizes_and_deduplicates_entries() {
let tools = parse_tools(Some(ToolsInput::List(vec![
"copilot".to_string(),
"github-copilot".to_string(),
"claudecode".to_string(),
"claude-code".to_string(),
"cursor".to_string(),
"CURSOR".to_string(),
])))
.expect("valid tools should parse");
assert_eq!(tools, vec!["github-copilot", "claude-code", "cursor"]);
}
#[test]
fn test_parse_tools_allows_compat_tool_names() {
let tools = parse_tools(Some(ToolsInput::Csv("generic,codex".to_string())))
.expect("generic and codex should be accepted for compatibility");
assert_eq!(tools, vec!["generic", "codex"]);
}
#[test]
fn test_parse_tools_rejects_unknown_tools() {
let result = parse_tools(Some(ToolsInput::List(vec!["claud-code".to_string()])));
let err = result.unwrap_err();
assert_eq!(
err.code,
ErrorCode::INVALID_PARAMS,
"unknown tool rejection must use INVALID_PARAMS (-32602)"
);
}
#[test]
fn test_make_invalid_params_error_code() {
let err = make_invalid_params("x".to_string());
assert_eq!(
err.code,
ErrorCode::INVALID_PARAMS,
"make_invalid_params must produce error code -32602"
);
}
#[test]
fn test_make_internal_error_error_code() {
let err = make_internal_error("x".to_string());
assert_eq!(
err.code,
ErrorCode::INTERNAL_ERROR,
"make_internal_error must produce error code -32603"
);
}
#[test]
fn test_apply_tool_selection_falls_back_to_target_when_tools_empty() {
let mut config = LintConfig::default();
apply_tool_selection(
&mut config,
Some(ToolsInput::Csv(" , ".to_string())),
Some("cursor".to_string()),
)
.expect("empty tools should fall back to target");
assert!(config.tools().is_empty());
assert_eq!(config.target(), TargetTool::Cursor);
}
#[test]
fn test_apply_tool_selection_falls_back_to_target_when_tools_missing() {
let mut config = LintConfig::default();
apply_tool_selection(&mut config, None, Some("claude-code".to_string()))
.expect("missing tools should fall back to target");
assert!(config.tools().is_empty());
assert_eq!(config.target(), TargetTool::ClaudeCode);
}
#[test]
fn test_apply_tool_selection_falls_back_to_target_when_tools_empty_list() {
let mut config = LintConfig::default();
apply_tool_selection(
&mut config,
Some(ToolsInput::List(vec![])),
Some("codex".to_string()),
)
.expect("empty list should fall back to target");
assert!(config.tools().is_empty());
assert_eq!(config.target(), TargetTool::Codex);
}
#[test]
fn test_apply_tool_selection_falls_back_to_target_when_target_is_kiro() {
let mut config = LintConfig::default();
apply_tool_selection(
&mut config,
Some(ToolsInput::Csv(" ".to_string())),
Some("kiro".to_string()),
)
.expect("empty tools should fall back to kiro target");
assert!(config.tools().is_empty());
assert_eq!(config.target(), TargetTool::Kiro);
}
#[test]
fn test_apply_tool_selection_clears_existing_tools_on_fallback() {
let mut config = LintConfig::default();
config.set_tools(vec!["cursor".to_string()]);
apply_tool_selection(
&mut config,
Some(ToolsInput::Csv(" ".to_string())),
Some("claude-code".to_string()),
)
.expect("empty tools should trigger fallback and clear stale tools");
assert!(config.tools().is_empty());
assert_eq!(config.target(), TargetTool::ClaudeCode);
}
#[test]
fn test_apply_tool_selection_prefers_tools_over_target() {
let mut config = LintConfig::default();
config.set_target(TargetTool::Cursor);
apply_tool_selection(
&mut config,
Some(ToolsInput::Csv("claude-code,cursor".to_string())),
Some("codex".to_string()),
)
.expect("valid tools should override target");
assert_eq!(config.tools(), &["claude-code", "cursor"]);
assert_eq!(config.target(), TargetTool::Generic);
}
#[test]
fn test_apply_tool_selection_rejects_unknown_tools() {
let mut config = LintConfig::default();
let result = apply_tool_selection(
&mut config,
Some(ToolsInput::Csv("unknown-tool".to_string())),
Some("claude-code".to_string()),
);
let err = result.unwrap_err();
assert_eq!(
err.code,
ErrorCode::INVALID_PARAMS,
"unknown tool rejection must use INVALID_PARAMS (-32602)"
);
assert!(config.tools().is_empty());
assert_eq!(config.target(), TargetTool::Generic);
}
#[test]
fn test_validate_file_input_deserializes_csv_tools_payload() {
let input: ValidateFileInput = serde_json::from_value(json!({
"path": "SKILL.md",
"tools": "claude-code,cursor",
"target": "codex"
}))
.expect("tools CSV payload should deserialize");
match input.tools {
Some(ToolsInput::Csv(value)) => assert_eq!(value, "claude-code,cursor"),
_ => panic!("expected CSV tools variant"),
}
assert_eq!(input.target.as_deref(), Some("codex"));
}
#[test]
fn test_validate_file_input_deserializes_array_tools_payload() {
let input: ValidateFileInput = serde_json::from_value(json!({
"path": "SKILL.md",
"tools": ["claude-code", "cursor"]
}))
.expect("tools array payload should deserialize");
match input.tools {
Some(ToolsInput::List(values)) => {
assert_eq!(values, vec!["claude-code", "cursor"]);
}
_ => panic!("expected array tools variant"),
}
assert!(input.target.is_none());
}
#[test]
fn test_validate_project_input_deserializes_csv_tools_payload() {
let input: ValidateProjectInput = serde_json::from_value(json!({
"path": ".",
"tools": "claude-code,cursor"
}))
.expect("project CSV tools payload should deserialize");
match input.tools {
Some(ToolsInput::Csv(value)) => assert_eq!(value, "claude-code,cursor"),
_ => panic!("expected CSV tools variant"),
}
}
#[test]
fn test_validate_project_input_deserializes_array_tools_payload() {
let input: ValidateProjectInput = serde_json::from_value(json!({
"path": ".",
"tools": ["claude-code", "cursor"]
}))
.expect("project array tools payload should deserialize");
match input.tools {
Some(ToolsInput::List(values)) => {
assert_eq!(values, vec!["claude-code", "cursor"]);
}
_ => panic!("expected array tools variant"),
}
}
#[test]
fn test_resolve_path_within_workspace_accepts_child_file() {
let temp = tempfile::TempDir::new().unwrap();
let file = temp.path().join("SKILL.md");
std::fs::write(&file, "---\nname: test\n---\n").unwrap();
let resolved = resolve_path_within_workspace("SKILL.md", temp.path())
.expect("workspace child path should resolve");
assert_eq!(resolved, file.canonicalize().unwrap());
}
#[test]
fn test_resolve_path_within_workspace_rejects_parent_traversal() {
let workspace = tempfile::TempDir::new().unwrap();
let outside = tempfile::TempDir::new().unwrap();
let outside_file = outside.path().join("secret.md");
std::fs::write(&outside_file, "secret").unwrap();
let traversal = format!(
"../{}/secret.md",
outside.path().file_name().unwrap().to_string_lossy()
);
let err = resolve_path_within_workspace(&traversal, workspace.path())
.expect_err("parent traversal must be rejected");
assert!(err.contains("outside workspace boundary"));
}
#[test]
fn test_resolve_path_within_workspace_rejects_absolute_outside_path() {
let workspace = tempfile::TempDir::new().unwrap();
let outside = tempfile::NamedTempFile::new().unwrap();
let err = resolve_path_within_workspace(outside.path().to_str().unwrap(), workspace.path())
.expect_err("absolute outside paths must be rejected");
assert!(err.contains("outside workspace boundary"));
}
#[test]
fn test_diagnostics_to_result_relativizes_workspace_paths() {
let workspace = tempfile::TempDir::new().unwrap();
let skill_path = workspace.path().join("SKILL.md");
std::fs::write(&skill_path, "---\nname: test\n---\n").unwrap();
let canonical_skill = skill_path.canonicalize().unwrap();
let diagnostic = Diagnostic::error(canonical_skill, 1, 1, "AS-001", "test diagnostic");
let result = diagnostics_to_result(
"SKILL.md",
vec![diagnostic],
1,
&workspace.path().canonicalize().unwrap(),
);
assert_eq!(result.path, "SKILL.md");
assert_eq!(result.diagnostics[0].file, "SKILL.md");
}
#[test]
fn test_display_path_for_client_uses_workspace_relative_path() {
let workspace = tempfile::TempDir::new().unwrap();
let nested = workspace.path().join("nested").join("SKILL.md");
let display = display_path_for_client(&nested, workspace.path());
assert_eq!(
display,
PathBuf::from("nested")
.join("SKILL.md")
.display()
.to_string()
);
}
#[test]
fn test_sanitize_error_message_redacts_workspace_root() {
let workspace = tempfile::TempDir::new().unwrap();
let message = format!(
"failed to read {}",
workspace.path().join("secret").join("SKILL.md").display()
);
let sanitized = sanitize_error_message(message, workspace.path());
assert!(!sanitized.contains(&workspace.path().display().to_string()));
assert!(sanitized.contains("."));
}
#[test]
fn test_run_validation_guarded_converts_panic_to_mcp_error() {
let workspace = tempfile::TempDir::new().unwrap();
let err = run_validation_guarded(
"validate file",
"SKILL.md",
workspace.path(),
|| -> Result<(), String> { panic!("intentional mcp panic") },
)
.expect_err("panic should be converted to an MCP error");
assert_eq!(err.code, ErrorCode::INTERNAL_ERROR);
assert!(err.message.contains("intentional mcp panic"));
}
#[test]
fn test_run_validation_guarded_sanitizes_validation_error() {
let workspace = tempfile::TempDir::new().unwrap();
let rooted_path = workspace.path().join("secret").join("SKILL.md");
let err = run_validation_guarded("validate file", "SKILL.md", workspace.path(), || {
Err::<(), LintError>(ValidationError::RootNotFound { path: rooted_path }.into())
})
.expect_err("validation error should become an MCP invalid params error");
assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
assert!(
!err.message
.contains(&workspace.path().display().to_string())
);
}
#[test]
fn test_tools_input_schema_prefers_array() {
let schema =
rmcp::schemars::SchemaGenerator::default().into_root_schema_for::<ToolsInput>();
let json = serde_json::to_value(&schema).expect("schema should serialize");
let any_of = json
.get("anyOf")
.and_then(|v| v.as_array())
.expect("schema should have anyOf array");
assert_eq!(any_of.len(), 2, "anyOf must have exactly two entries");
assert_eq!(
any_of[0].get("type").and_then(|v| v.as_str()),
Some("array"),
"first anyOf entry must be the array variant"
);
assert_eq!(
any_of[1].get("type").and_then(|v| v.as_str()),
Some("string"),
"second anyOf entry must be the string variant"
);
assert_eq!(
any_of[0]
.get("items")
.and_then(|v| v.get("type"))
.and_then(|v| v.as_str()),
Some("string"),
"array variant must have items.type == 'string'"
);
}
#[test]
fn test_tools_input_schema_has_variant_descriptions() {
let schema =
rmcp::schemars::SchemaGenerator::default().into_root_schema_for::<ToolsInput>();
let json = serde_json::to_value(&schema).expect("schema should serialize");
let any_of = json
.get("anyOf")
.and_then(|v| v.as_array())
.expect("schema should have anyOf array");
let array_desc = any_of[0]
.get("description")
.and_then(|v| v.as_str())
.expect("array variant should have description");
assert!(
array_desc.contains("Preferred"),
"array variant description should contain 'Preferred', got: {}",
array_desc
);
let string_desc = any_of[1]
.get("description")
.and_then(|v| v.as_str())
.expect("string variant should have description");
assert!(
string_desc.contains("Fallback"),
"string variant description should contain 'Fallback', got: {}",
string_desc
);
}
#[test]
fn test_tools_input_deserialization_after_reorder() {
let list: ToolsInput =
serde_json::from_value(json!(["claude-code", "cursor"])).expect("array should parse");
match list {
ToolsInput::List(values) => assert_eq!(values, vec!["claude-code", "cursor"]),
ToolsInput::Csv(_) => panic!("expected List variant for JSON array input"),
}
let csv: ToolsInput =
serde_json::from_value(json!("claude-code,cursor")).expect("string should parse");
match csv {
ToolsInput::Csv(value) => assert_eq!(value, "claude-code,cursor"),
ToolsInput::List(_) => panic!("expected Csv variant for JSON string input"),
}
}
#[test]
fn test_validate_file_input_schema_tools_description() {
let schema =
rmcp::schemars::SchemaGenerator::default().into_root_schema_for::<ValidateFileInput>();
let json = serde_json::to_value(&schema).expect("schema should serialize");
let json_str = serde_json::to_string(&json).unwrap();
assert!(
json_str.contains("Preferred"),
"ValidateFileInput schema must mention 'Preferred' for tools field"
);
assert!(
json_str.contains("fallback"),
"ValidateFileInput schema must mention 'fallback' for tools field"
);
}
#[test]
fn test_validate_project_input_schema_tools_description() {
let schema = rmcp::schemars::SchemaGenerator::default()
.into_root_schema_for::<ValidateProjectInput>();
let json = serde_json::to_value(&schema).expect("schema should serialize");
let json_str = serde_json::to_string(&json).unwrap();
assert!(
json_str.contains("Preferred"),
"ValidateProjectInput schema must mention 'Preferred' for tools field"
);
assert!(
json_str.contains("fallback"),
"ValidateProjectInput schema must mention 'fallback' for tools field"
);
}
}