use std::collections::{HashMap, HashSet};
use std::path::Path;
use regex::Regex;
use serde_json::Value;
use url::Url;
use crate::analysis::{line_column, push};
use crate::scanner::{FileKind, SourceFile};
use crate::{Diagnostic, Severity};
pub fn check(root: &Path, file: &SourceFile, diagnostics: &mut Vec<Diagnostic>) {
match file.kind {
FileKind::McpJson => check_json(root, file, diagnostics),
FileKind::McpYaml => check_yaml(root, file, diagnostics),
FileKind::Instructions | FileKind::Skill => check_frontmatter(file, diagnostics),
FileKind::ConfigJson => check_generic_json(file, diagnostics),
FileKind::ConfigYaml => check_generic_yaml(file, diagnostics),
FileKind::ConfigToml => check_generic_toml(file, diagnostics),
}
}
fn check_generic_json(file: &SourceFile, diagnostics: &mut Vec<Diagnostic>) {
check_duplicate_json_keys(file, diagnostics);
if let Err(error) = serde_json::from_str::<Value>(&file.content) {
push(
diagnostics,
Diagnostic::new(
"AGL001",
Severity::Error,
&file.relative_path,
format!("Invalid JSON: {error}"),
)
.at(error.line(), error.column()),
);
}
}
fn check_generic_yaml(file: &SourceFile, diagnostics: &mut Vec<Diagnostic>) {
if let Err(error) = serde_yaml::from_str::<serde_yaml::Value>(&file.content) {
let mut diagnostic = Diagnostic::new(
"AGL001",
Severity::Error,
&file.relative_path,
format!("Invalid YAML: {error}"),
);
if let Some(location) = error.location() {
diagnostic = diagnostic.at(location.line(), location.column());
}
push(diagnostics, diagnostic);
}
}
fn check_generic_toml(file: &SourceFile, diagnostics: &mut Vec<Diagnostic>) {
if let Err(error) = toml::from_str::<toml::Value>(&file.content) {
let span = error.span().unwrap_or(0..0);
let (line, column) = line_column(&file.content, span.start);
push(
diagnostics,
Diagnostic::new(
"AGL001",
Severity::Error,
&file.relative_path,
format!("Invalid TOML: {error}"),
)
.at(line, column),
);
}
}
fn check_json(root: &Path, file: &SourceFile, diagnostics: &mut Vec<Diagnostic>) {
check_duplicate_json_keys(file, diagnostics);
match serde_json::from_str::<Value>(&file.content) {
Ok(value) => validate_mcp(root, file, &value, diagnostics),
Err(error) => {
push(
diagnostics,
Diagnostic::new(
"AGL001",
Severity::Error,
&file.relative_path,
format!("Invalid JSON: {error}"),
)
.at(error.line(), error.column())
.suggest("Fix the JSON syntax before loading this MCP configuration."),
);
}
}
}
fn check_yaml(root: &Path, file: &SourceFile, diagnostics: &mut Vec<Diagnostic>) {
match serde_yaml::from_str::<serde_yaml::Value>(&file.content) {
Ok(yaml) => match serde_json::to_value(yaml) {
Ok(value) => validate_mcp(root, file, &value, diagnostics),
Err(error) => push(
diagnostics,
Diagnostic::new(
"AGL001",
Severity::Error,
&file.relative_path,
format!("Invalid YAML value: {error}"),
),
),
},
Err(error) => {
let location = error.location();
let mut diagnostic = Diagnostic::new(
"AGL001",
Severity::Error,
&file.relative_path,
format!("Invalid YAML: {error}"),
);
if let Some(location) = location {
diagnostic = diagnostic.at(location.line(), location.column());
}
push(diagnostics, diagnostic);
}
}
}
fn check_frontmatter(file: &SourceFile, diagnostics: &mut Vec<Diagnostic>) {
if !file.content.starts_with("---\n") && !file.content.starts_with("---\r\n") {
if file.kind == FileKind::Skill {
push(
diagnostics,
Diagnostic::new(
"AGL002",
Severity::Error,
&file.relative_path,
"SKILL.md requires YAML frontmatter with `name` and `description`.",
)
.at(1, 1),
);
}
return;
}
let mut offset = 0;
let mut closing = None;
for (index, line) in file.content.split_inclusive('\n').enumerate() {
if index > 0 && line.trim_end_matches(['\r', '\n']) == "---" {
closing = Some(offset);
break;
}
offset += line.len();
}
let Some(closing) = closing else {
push(
diagnostics,
Diagnostic::new(
"AGL002",
Severity::Error,
&file.relative_path,
"Frontmatter is missing its closing `---` delimiter.",
)
.at(1, 1)
.suggest("Add a closing `---` line before the Markdown content."),
);
return;
};
let first_line_end = file.content.find('\n').map_or(3, |position| position + 1);
let yaml = &file.content[first_line_end..closing];
let value = match serde_yaml::from_str::<serde_yaml::Value>(yaml) {
Ok(value) => value,
Err(error) => {
let location = error.location();
let (line, column) =
location.map_or((2, 1), |location| (location.line() + 1, location.column()));
push(
diagnostics,
Diagnostic::new(
"AGL002",
Severity::Error,
&file.relative_path,
format!("Malformed Markdown frontmatter: {error}"),
)
.at(line, column),
);
return;
}
};
let Some(mapping) = value.as_mapping() else {
push(
diagnostics,
Diagnostic::new(
"AGL002",
Severity::Error,
&file.relative_path,
"Frontmatter must be a key-value mapping.",
)
.at(2, 1),
);
return;
};
validate_frontmatter_mapping(file, mapping, diagnostics);
}
fn validate_frontmatter_mapping(
file: &SourceFile,
mapping: &serde_yaml::Mapping,
diagnostics: &mut Vec<Diagnostic>,
) {
let allowed: HashSet<&str> = if file.kind == FileKind::Skill {
[
"name",
"description",
"license",
"compatibility",
"metadata",
"allowed-tools",
]
.into_iter()
.collect()
} else {
["description", "globs", "alwaysApply"]
.into_iter()
.collect()
};
for key in mapping.keys().filter_map(serde_yaml::Value::as_str) {
if !allowed.contains(key) {
push(
diagnostics,
Diagnostic::new(
"AGL102",
Severity::Warning,
&file.relative_path,
format!("Unsupported frontmatter field `{key}`."),
)
.at(find_line(&file.content, key), 1)
.suggest(format!(
"Remove `{key}` or use a field supported by this instruction format."
)),
);
}
}
if file.kind == FileKind::Skill {
for required in ["name", "description"] {
if mapping
.get(serde_yaml::Value::String(required.into()))
.and_then(serde_yaml::Value::as_str)
.is_none_or(|value| value.trim().is_empty())
{
push(
diagnostics,
Diagnostic::new(
"AGL002",
Severity::Error,
&file.relative_path,
format!("SKILL.md frontmatter requires a non-empty `{required}` field."),
)
.at(2, 1),
);
}
}
}
if let Some(globs) = mapping.get(serde_yaml::Value::String("globs".into())) {
validate_globs(file, globs, diagnostics);
}
}
fn validate_globs(file: &SourceFile, value: &serde_yaml::Value, diagnostics: &mut Vec<Diagnostic>) {
let values: Vec<&str> = match value {
serde_yaml::Value::String(item) => item.split(',').map(str::trim).collect(),
serde_yaml::Value::Sequence(items) => {
items.iter().filter_map(serde_yaml::Value::as_str).collect()
}
_ => {
push(
diagnostics,
Diagnostic::new(
"AGL002",
Severity::Error,
&file.relative_path,
"Frontmatter `globs` must be a string or list of strings.",
)
.at(find_line(&file.content, "globs"), 1),
);
return;
}
};
for glob in values {
if let Err(error) = globset::Glob::new(glob) {
push(
diagnostics,
Diagnostic::new(
"AGL201",
Severity::Error,
&file.relative_path,
format!("Invalid instruction glob `{glob}`: {error}"),
)
.at(find_line(&file.content, glob), 1),
);
}
}
}
fn validate_mcp(root: &Path, file: &SourceFile, value: &Value, diagnostics: &mut Vec<Diagnostic>) {
let Some(top) = value.as_object() else {
push(
diagnostics,
Diagnostic::new(
"AGL101",
Severity::Error,
&file.relative_path,
"MCP configuration must be a JSON/YAML object.",
),
);
return;
};
let servers = top
.get("mcpServers")
.or_else(|| top.get("servers"))
.unwrap_or(value);
let Some(servers) = servers.as_object() else {
push(
diagnostics,
Diagnostic::new(
"AGL101",
Severity::Error,
&file.relative_path,
"`mcpServers` or `servers` must be an object keyed by server name.",
),
);
return;
};
if servers.is_empty() {
push(
diagnostics,
Diagnostic::new(
"AGL101",
Severity::Error,
&file.relative_path,
"MCP configuration contains no server definitions.",
),
);
}
for (name, server) in servers {
validate_server(root, file, name, server, diagnostics);
}
}
fn validate_server(
root: &Path,
file: &SourceFile,
name: &str,
value: &Value,
diagnostics: &mut Vec<Diagnostic>,
) {
let line = find_line(&file.content, &format!("\"{name}\""));
let Some(server) = value.as_object() else {
push(
diagnostics,
Diagnostic::new(
"AGL101",
Severity::Error,
&file.relative_path,
format!("MCP server `{name}` must be an object."),
)
.at(line, 1),
);
return;
};
if server.is_empty() {
push(
diagnostics,
Diagnostic::new(
"AGL101",
Severity::Error,
&file.relative_path,
format!("MCP server `{name}` is empty."),
)
.at(line, 1),
);
return;
}
let command = server.get("command");
let url = server.get("url");
if command.is_none() && url.is_none() {
push(
diagnostics,
Diagnostic::new(
"AGL101",
Severity::Error,
&file.relative_path,
format!("MCP server `{name}` needs a non-empty `command` or `url`."),
)
.at(line, 1),
);
}
if let Some(command) = command {
if let Some(command) = command.as_str().filter(|value| !value.trim().is_empty()) {
check_executable(root, file, name, command, diagnostics);
} else {
push(
diagnostics,
Diagnostic::new(
"AGL101",
Severity::Error,
&file.relative_path,
format!("MCP server `{name}` has an invalid or empty `command`."),
)
.at(line, 1),
);
}
}
validate_server_args(root, file, name, server.get("args"), diagnostics);
validate_server_env(root, file, server.get("env"), diagnostics);
validate_server_cwd(root, file, name, server.get("cwd"), diagnostics);
if let Some(url) = url {
match url.as_str().and_then(|raw| Url::parse(raw).ok()) {
Some(parsed) if matches!(parsed.scheme(), "http" | "https") => {}
_ => push(
diagnostics,
Diagnostic::new(
"AGL103",
Severity::Error,
&file.relative_path,
format!("MCP server `{name}` has a malformed or unsupported URL."),
)
.at(find_line(&file.content, "\"url\""), 1)
.suggest("Use a complete http:// or https:// URL."),
),
}
}
check_unknown_server_fields(file, name, server, diagnostics);
}
fn check_unknown_server_fields(
file: &SourceFile,
name: &str,
server: &serde_json::Map<String, Value>,
diagnostics: &mut Vec<Diagnostic>,
) {
let known = [
"command",
"args",
"env",
"cwd",
"url",
"type",
"headers",
"transport",
"disabled",
"autoApprove",
"timeout",
];
for key in server.keys().filter(|key| !known.contains(&key.as_str())) {
push(
diagnostics,
Diagnostic::new(
"AGL102",
Severity::Warning,
&file.relative_path,
format!("MCP server `{name}` uses unsupported field `{key}`."),
)
.at(find_line(&file.content, &format!("\"{key}\"")), 1),
);
}
}
fn validate_server_args(
root: &Path,
file: &SourceFile,
name: &str,
args: Option<&Value>,
diagnostics: &mut Vec<Diagnostic>,
) {
let Some(args) = args else { return };
if !args
.as_array()
.is_some_and(|items| items.iter().all(Value::is_string))
{
push(
diagnostics,
Diagnostic::new(
"AGL101",
Severity::Error,
&file.relative_path,
format!("MCP server `{name}` field `args` must be an array of strings."),
)
.at(find_line(&file.content, "\"args\""), 1),
);
return;
}
for argument in args
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
{
if is_local_file_argument(argument) && !root.join(argument).exists() {
push(
diagnostics,
Diagnostic::new(
"AGL202",
Severity::Warning,
&file.relative_path,
format!("MCP server `{name}` references missing argument path `{argument}`."),
)
.at(find_line(&file.content, argument), 1)
.reference(argument),
);
}
}
}
fn validate_server_env(
root: &Path,
file: &SourceFile,
env: Option<&Value>,
diagnostics: &mut Vec<Diagnostic>,
) {
let Some(env) = env else { return };
if !env
.as_object()
.is_some_and(|items| items.values().all(Value::is_string))
{
push(
diagnostics,
Diagnostic::new(
"AGL101",
Severity::Error,
&file.relative_path,
"MCP server `env` must map names to string values.",
)
.at(find_line(&file.content, "\"env\""), 1),
);
return;
}
for (key, value) in env.as_object().into_iter().flatten() {
if value
.as_str()
.is_some_and(|value| value == format!("${{{key}}}") || value == format!("${key}"))
&& !environment_documented(root, key)
{
push(
diagnostics,
Diagnostic::new(
"AGL105",
Severity::Warning,
&file.relative_path,
format!("Environment variable `{key}` is referenced but not documented in an environment template."),
)
.at(find_line(&file.content, key), 1)
.reference(key)
.suggest(format!("Add `{key}=` to .env.example without including a real secret.")),
);
}
}
}
fn validate_server_cwd(
root: &Path,
file: &SourceFile,
name: &str,
cwd: Option<&Value>,
diagnostics: &mut Vec<Diagnostic>,
) {
let Some(cwd) = cwd else { return };
let Some(cwd) = cwd.as_str() else {
push(
diagnostics,
Diagnostic::new(
"AGL101",
Severity::Error,
&file.relative_path,
format!("MCP server `{name}` field `cwd` must be a string."),
)
.at(find_line(&file.content, "\"cwd\""), 1),
);
return;
};
if !root.join(cwd).is_dir() {
push(
diagnostics,
Diagnostic::new(
"AGL202",
Severity::Warning,
&file.relative_path,
format!("MCP server `{name}` references missing working directory `{cwd}`."),
)
.at(find_line(&file.content, cwd), 1)
.reference(cwd),
);
}
}
fn check_executable(
root: &Path,
file: &SourceFile,
server: &str,
command: &str,
diagnostics: &mut Vec<Diagnostic>,
) {
let is_path = command.contains('/') || command.contains('\\');
let exists = if is_path {
let path = Path::new(command);
(if path.is_absolute() {
path.to_path_buf()
} else {
root.join(path)
})
.is_file()
} else {
which::which(command).is_ok()
};
if exists {
return;
}
let mut diagnostic = Diagnostic::new(
"AGL104",
Severity::Error,
&file.relative_path,
format!("MCP server `{server}` references executable `{command}`, which is not available in PATH."),
)
.at(find_line(&file.content, command), 1);
if let Some(suggestion) = closest_command(command) {
diagnostic = diagnostic.suggest(format!("Did you mean `{suggestion}`?"));
}
push(diagnostics, diagnostic);
}
fn closest_command(command: &str) -> Option<&'static str> {
[
"npx", "npm", "pnpm", "yarn", "bun", "node", "python", "cargo", "docker",
]
.into_iter()
.filter_map(|candidate| {
let distance = strsim::levenshtein(command, candidate);
(distance <= 2).then_some((candidate, distance))
})
.min_by_key(|(_, distance)| *distance)
.map(|(candidate, _)| candidate)
}
fn is_local_file_argument(value: &str) -> bool {
(value.starts_with("./") || value.starts_with("../") || value.starts_with("scripts/"))
&& Path::new(value).extension().is_some()
}
fn environment_documented(root: &Path, name: &str) -> bool {
[".env.example", ".env.sample", ".env.template"]
.iter()
.filter_map(|file| std::fs::read_to_string(root.join(file)).ok())
.any(|content| {
content
.lines()
.any(|line| line.trim_start().starts_with(&format!("{name}=")))
})
}
fn check_duplicate_json_keys(file: &SourceFile, diagnostics: &mut Vec<Diagnostic>) {
let Ok(key_pattern) = Regex::new(r#"(?m)^[ \t]*\"([^\"\\]+)\"[ \t]*:"#) else {
return;
};
let mut seen: HashMap<(usize, String), usize> = HashMap::new();
for capture in key_pattern.captures_iter(&file.content) {
let Some(full) = capture.get(0) else { continue };
let scope = json_object_scope(&file.content[..full.start()]);
let key = capture[1].to_owned();
if let Some(first_line) = seen.insert(
(scope, key.clone()),
line_column(&file.content, full.start()).0,
) {
let (line, column) = line_column(&file.content, full.start());
push(
diagnostics,
Diagnostic::new(
"AGL003",
Severity::Error,
&file.relative_path,
format!("Duplicate key `{key}`; first defined on line {first_line}."),
)
.at(line, column),
);
}
}
}
fn json_object_scope(text: &str) -> usize {
let mut positions = Vec::new();
let mut string = false;
let mut escaped = false;
for (index, character) in text.char_indices() {
if string {
if escaped {
escaped = false;
} else if character == '\\' {
escaped = true;
} else if character == '"' {
string = false;
}
} else {
match character {
'"' => string = true,
'{' => positions.push(index),
'}' => {
positions.pop();
}
_ => {}
}
}
}
positions.last().copied().unwrap_or(0)
}
fn find_line(text: &str, needle: &str) -> usize {
text.find(needle)
.map_or(1, |offset| line_column(text, offset).0)
}